inicialização do repo
This commit is contained in:
commit
826fccf678
26
.dockerignore
Normal file
26
.dockerignore
Normal file
@ -0,0 +1,26 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
*.log
|
||||
*.pot
|
||||
*.mo
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
db.sqlite3
|
||||
*.sqlite3
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
@ -0,0 +1,26 @@
|
||||
# Configurações do Django
|
||||
DEBUG=False
|
||||
SECRET_KEY=sua-chave-secreta-aleatoria-muito-segura-aqui
|
||||
APP_CONTAINER_NAME=aprovacao-pedidos-novo
|
||||
HOST_PORT=8010
|
||||
|
||||
# ALLOWED_HOSTS - adicione seu domínio
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,seu-dominio.com
|
||||
|
||||
# API Suprimentos
|
||||
API_SUPRIMENTOS_TOKEN=your-api-token-here
|
||||
API_SUPRIMENTOS_DETALHE_URL=https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente
|
||||
API_SUPRIMENTOS_IMPLANTACAO_URL=https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000
|
||||
|
||||
# SQL Server (caso necessite - deixe em branco se usar SQLite)
|
||||
SQL_SERVER_HOST=10.77.77.10
|
||||
SQL_SERVER_DATABASE=GINSENG
|
||||
SQL_SERVER_USERNAME=suprimentos
|
||||
SQL_SERVER_PASSWORD=sua-senha-aqui
|
||||
|
||||
# Segurança HTTPS/proxy (produção)
|
||||
CSRF_TRUSTED_ORIGINS=https://seu-dominio.com
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SECURE_HSTS_SECONDS=0
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=False
|
||||
SECURE_HSTS_PRELOAD=False
|
||||
28
.env.production
Normal file
28
.env.production
Normal file
@ -0,0 +1,28 @@
|
||||
# ⚠️ ARQUIVO DE EXEMPLO PARA PRODUÇÃO
|
||||
# Copie este arquivo para .env e preencha com suas credenciais reais
|
||||
# Este arquivo NÃO deve ser commitado no Git
|
||||
|
||||
# Django
|
||||
DEBUG=False
|
||||
SECRET_KEY=gera-uma-chave-segura-com-mais-de-50-caracteres-aleatorios-aqui-12345
|
||||
ALLOWED_HOSTS=seu-dominio.com,www.seu-dominio.com
|
||||
APP_CONTAINER_NAME=aprovacao-pedidos-novo
|
||||
HOST_PORT=8010
|
||||
|
||||
# API Suprimentos
|
||||
API_SUPRIMENTOS_TOKEN=seu-token-api-seguro-aqui
|
||||
API_SUPRIMENTOS_DETALHE_URL=https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente
|
||||
API_SUPRIMENTOS_IMPLANTACAO_URL=https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000
|
||||
|
||||
# SQL Server (opcional - deixe em branco se usar SQLite)
|
||||
SQL_SERVER_HOST=10.77.77.10
|
||||
SQL_SERVER_DATABASE=GINSENG
|
||||
SQL_SERVER_USERNAME=suprimentos
|
||||
SQL_SERVER_PASSWORD=sua-senha-sql-server-aqui
|
||||
|
||||
# Cookies seguros (HTTPS)
|
||||
CSRF_TRUSTED_ORIGINS=https://seu-dominio.com,https://www.seu-dominio.com
|
||||
SECURE_SSL_REDIRECT=True
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||
SECURE_HSTS_PRELOAD=True
|
||||
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@ -0,0 +1,141 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Static files
|
||||
staticfiles/
|
||||
208
COMECE_AQUI.md
Normal file
208
COMECE_AQUI.md
Normal file
@ -0,0 +1,208 @@
|
||||
# 🚀 GUIA RÁPIDO - Primeiros Passos
|
||||
|
||||
## ⚡ Início Rápido (5 minutos)
|
||||
|
||||
### 1️⃣ Instalar Dependências
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2️⃣ Configurar Token da API
|
||||
Na raiz do projeto, edite o arquivo `.env`:
|
||||
|
||||
```bash
|
||||
# Abrir ou criar .env na raiz
|
||||
# Linux/Mac:
|
||||
nano .env
|
||||
|
||||
# Windows:
|
||||
notepad .env
|
||||
```
|
||||
|
||||
Conteúdo do arquivo:
|
||||
```
|
||||
API_SUPRIMENTOS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDk1Mzg4NzcsInVzZXJuYW1lIjoic3VwcmltZW50b3MuZ2luc2VuZyJ9.VyQOfFKOf56_GqmECfuQ29Y5sEGtOopVS4XC-cA54DU
|
||||
API_SUPRIMENTOS_DETALHE_URL=https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente
|
||||
API_SUPRIMENTOS_IMPLANTACAO_URL=https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000
|
||||
```
|
||||
|
||||
### 3️⃣ Testar a Integração
|
||||
```bash
|
||||
python test_api_integration.py
|
||||
```
|
||||
|
||||
Você deve ver:
|
||||
```
|
||||
🧪 TESTES DE INTEGRAÇÃO COM API DE SUPRIMENTOS
|
||||
|
||||
✓ PASSOU: Conexão com API
|
||||
✓ PASSOU: Merge de dados
|
||||
✓ PASSOU: Integração completa
|
||||
|
||||
🎉 Todos os testes passaram! Integração pronta para usar.
|
||||
```
|
||||
|
||||
### 4️⃣ Rodar o Servidor
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
Acesse: `http://localhost:8000/home/`
|
||||
|
||||
## 📊 O que mudou?
|
||||
|
||||
### Tabela agora mostra:
|
||||
- **PDV** (de ambas fontes)
|
||||
- **SKU** (de ambas fontes)
|
||||
- **Estoque** (do SQL)
|
||||
- **Trânsito** (do SQL)
|
||||
- **Pendente** (do SQL)
|
||||
- **sugestao_analista** ← **NOVO! (da API)**
|
||||
|
||||
### Novos Campos no JSON:
|
||||
```json
|
||||
{
|
||||
"PDV": "2001",
|
||||
"SKU": "ABC123",
|
||||
"sugestao_analista": 45, // ← DA API
|
||||
"dados_api_presentes": true, // ← FLAG
|
||||
"Estoque_Total": 150, // Do SQL
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 Proteção do Token
|
||||
|
||||
✅ **Arquivo `.env` não é versionado**
|
||||
✅ **Arquivo `.env.example` documenta os campos**
|
||||
✅ **Token nunca aparece no código**
|
||||
|
||||
**IMPORTANTE:** Nunca faça commit do arquivo `.env`!
|
||||
|
||||
```bash
|
||||
# Verificar se está protegido:
|
||||
cat .gitignore | grep .env
|
||||
# Deve mostrar: .env
|
||||
```
|
||||
|
||||
## ❌ Resolução de Problemas
|
||||
|
||||
### Erro: "Token não configurado"
|
||||
```
|
||||
❌ ERRO: Token não configurado no .env
|
||||
```
|
||||
**Solução:** Crie/edite o arquivo `.env` na raiz do projeto
|
||||
|
||||
### Erro: "Falha ao conectar com a API"
|
||||
```
|
||||
❌ Falha ao conectar com a API (verifique token e conexão de rede)
|
||||
```
|
||||
**Solução:**
|
||||
- Verifique se o token é válido
|
||||
- Teste a conexão: `ping api.grupoginseng.com.br`
|
||||
- Verifique firewall/VPN
|
||||
|
||||
### Dados não aparecem
|
||||
```
|
||||
⚠️ API retornou lista vazia
|
||||
```
|
||||
**Solução:**
|
||||
- Verifique se há dados pendentes na API
|
||||
- Confirme PDV e SKU estão corretos
|
||||
|
||||
## 📱 Monitorando a Integração
|
||||
|
||||
### Ver logs no terminal
|
||||
```bash
|
||||
# Quando você roda: python manage.py runserver
|
||||
|
||||
[2024-XX-XX XX:XX:XX] Dados da API integrados: 250 registros
|
||||
[2024-XX-XX XX:XX:XX] GET /home/api/pivot-data/ 200 OK
|
||||
```
|
||||
|
||||
### Debug no Python
|
||||
```bash
|
||||
python manage.py shell
|
||||
|
||||
# Dentro do shell:
|
||||
>>> from home.views import get_api_suprimentos_data
|
||||
>>> dados = get_api_suprimentos_data()
|
||||
>>> print(f"Total: {len(dados) if dados else 'None'} registros")
|
||||
>>> print(dados[0] if dados else "Sem dados")
|
||||
```
|
||||
|
||||
## 📦 Arquitetura da Solução
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Página Web │
|
||||
│ /home/api/pivot-data/ │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ get_pivot_data │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌───────┴────────────────┐
|
||||
│ │
|
||||
┌────▼──────┐ ┌────────▼─────┐
|
||||
│ SQL Server │ │ API Suprimentos│
|
||||
│(dados base)│ │(quantidade) │
|
||||
└────┬──────┘ └────────┬─────┘
|
||||
│ │
|
||||
└───────────┬────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ merge_api_with_sql_data() │
|
||||
│ (JOIN por PDV + SKU) │
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ Dados Combinados │
|
||||
│ sugestao_analista = qty │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
## 📋 Checklist
|
||||
|
||||
- [ ] `.env` criado na raiz
|
||||
- [ ] Token adicionado ao `.env`
|
||||
- [ ] Dependências instaladas: `pip install -r requirements.txt`
|
||||
- [ ] Teste passou: `python test_api_integration.py`
|
||||
- [ ] Servidor rodando: `python manage.py runserver`
|
||||
- [ ] Página abre: `http://localhost:8000/home/`
|
||||
- [ ] Dados aparecem com "sugestao_analista"
|
||||
- [ ] `.env` está no `.gitignore`
|
||||
|
||||
## 🎓 Referências
|
||||
|
||||
- 📘 [INTEGRACAO_API.md](INTEGRACAO_API.md) - Documentação técnica completa
|
||||
- 📘 [IMPLEMENTACAO_RESUMO.md](IMPLEMENTACAO_RESUMO.md) - O que foi implementado
|
||||
- 🧪 [test_api_integration.py](test_api_integration.py) - Script de teste
|
||||
|
||||
## 💬 Dúvidas Frequentes
|
||||
|
||||
**P: Preciso reiniciar o servidor a cada mudança do `.env`?**
|
||||
R: Sim, o Django lê as variáveis ao iniciar. Reinicie com: `Ctrl+C` e `python manage.py runserver`
|
||||
|
||||
**P: E se a API cair?**
|
||||
R: Site continua funcionando normalmente com dados do SQL Server.
|
||||
|
||||
**P: Como adicionar novo token?**
|
||||
R: Edite `.env`, reinicie servidor.
|
||||
|
||||
**P: Posso compartilhar o `.env` com a equipe?**
|
||||
R: Não! Use variáveis de ambiente por servidor. Cada dev tem seu `.env` local.
|
||||
|
||||
## 🚀 Próximo Passo
|
||||
|
||||
Você está pronto! Execute:
|
||||
|
||||
```bash
|
||||
python test_api_integration.py
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
E acesse: **http://localhost:8000/home/**
|
||||
|
||||
Boa sorte! 🎉
|
||||
123
DEPLOY_PRODUCAO.md
Normal file
123
DEPLOY_PRODUCAO.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 🚀 Guia de Deploy em Produção
|
||||
|
||||
## Configuração para Produção
|
||||
|
||||
Este aplicativo foi otimizado para rodar em produção com máxima segurança.
|
||||
|
||||
### 1️⃣ Preparar Arquivo .env
|
||||
|
||||
Crie um arquivo `.env` na raiz do projeto com as configurações seguras:
|
||||
|
||||
```env
|
||||
# Django
|
||||
DEBUG=False
|
||||
SECRET_KEY=gera-uma-chave-segura-com-32-caracteres-aleatorios-aqui
|
||||
ALLOWED_HOSTS=seu-dominio.com,www.seu-dominio.com,seu-ip.com
|
||||
|
||||
# API Suprimentos
|
||||
API_SUPRIMENTOS_TOKEN=seu-token-aqui
|
||||
API_SUPRIMENTOS_DETALHE_URL=https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente
|
||||
API_SUPRIMENTOS_IMPLANTACAO_URL=https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000
|
||||
|
||||
# SQL Server (deixe em branco se usar SQLite)
|
||||
SQL_SERVER_HOST=10.77.77.10
|
||||
SQL_SERVER_DATABASE=GINSENG
|
||||
SQL_SERVER_USERNAME=suprimentos
|
||||
SQL_SERVER_PASSWORD=sua-senha-segura
|
||||
```
|
||||
|
||||
**⚠️ IMPORTANTE:**
|
||||
- **Gere uma SECRET_KEY segura** (mínimo 50 caracteres aleatórios)
|
||||
- **NUNCA** commit o arquivo `.env` em repositórios públicos
|
||||
- Está no `.gitignore`, portanto será automaticamente ignorado
|
||||
|
||||
### 2️⃣ Gerar SECRET_KEY Segura
|
||||
|
||||
```python
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
print(get_random_secret_key())
|
||||
```
|
||||
|
||||
### 3️⃣ Build e Deploy com Docker
|
||||
|
||||
```bash
|
||||
# Build da imagem
|
||||
docker-compose build
|
||||
|
||||
# Iniciar em produção
|
||||
docker-compose up -d
|
||||
|
||||
# Verificar logs
|
||||
docker-compose logs -f web
|
||||
|
||||
# Parar
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 4️⃣ Verificações de Segurança
|
||||
|
||||
Antes de publicar, verifique:
|
||||
|
||||
```bash
|
||||
python manage.py check --deploy
|
||||
```
|
||||
|
||||
Deve exibir:
|
||||
- ✅ DEBUG=False
|
||||
- ✅ SECRET_KEY configurada
|
||||
- ✅ ALLOWED_HOSTS definidos
|
||||
- ✅ Cookies seguros habilitados
|
||||
|
||||
### 5️⃣ Reverse Proxy (Nginx Recomendado)
|
||||
|
||||
Para produção, use um **reverse proxy** (Nginx) na frente do Gunicorn:
|
||||
|
||||
```nginx
|
||||
upstream aprovacao_pedidos {
|
||||
server web:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://aprovacao_pedidos;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /app/staticfiles/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mudanças Implementadas ✅
|
||||
|
||||
| Aspecto | Antes | Depois |
|
||||
|--------|-------|--------|
|
||||
| Servidor WSGI | `python manage.py runserver` | `gunicorn` (4x mais rápido) |
|
||||
| Modo DEBUG | `True` (inseguro) | `False` (seguro) |
|
||||
| SECRET_KEY | Hardcoded/exposta | Variável de ambiente |
|
||||
| ALLOWED_HOSTS | `['*']` (inseguro) | Domínios específicos |
|
||||
| Credenciais | Código-fonte | Arquivo `.env` (ignorado) |
|
||||
| Cookies HTTPS | Não | Sim (em produção) |
|
||||
| Volumes Docker | Expõe código | Removido (apenas em dev) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Erro: "DisallowedHost"**
|
||||
- Adicione seu domínio em `ALLOWED_HOSTS`
|
||||
|
||||
**Erro: "SECRET_KEY not found"**
|
||||
- Crie arquivo `.env` com `SECRET_KEY`
|
||||
|
||||
**Erro: "Connection refused"**
|
||||
- Verifique se Gunicorn está rodando: `docker-compose logs web`
|
||||
|
||||
---
|
||||
|
||||
**📝 Próximos passos:** Usar HTTPS/SSL, implementar CI/CD, monitorar logs
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . /app/
|
||||
|
||||
# Startup script
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Expose port 8000
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application with gunicorn (production-grade WSGI server)
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
222
IMPLEMENTACAO_RESUMO.md
Normal file
222
IMPLEMENTACAO_RESUMO.md
Normal file
@ -0,0 +1,222 @@
|
||||
# 📦 Resumo de Implementação - Integração API de Suprimentos
|
||||
|
||||
## ✅ O que foi implementado
|
||||
|
||||
### 1. **Armazenamento Seguro do Token** 🔐
|
||||
- ✅ Arquivo `.env` criado com token JWT
|
||||
- ✅ `python-dotenv` adicionado ao `requirements.txt`
|
||||
- ✅ Arquivo `.env` adicionado ao `.gitignore` (proteção contra exposição)
|
||||
- ✅ Arquivo `.env.example` criado para documentação
|
||||
|
||||
**Proteção:**
|
||||
- Token nunca fica no código fonte
|
||||
- Arquivo `.env` nunca é commitado no Git
|
||||
- Configuração por variáveis de ambiente
|
||||
|
||||
### 2. **Configuração no Django** ⚙️
|
||||
- ✅ `settings.py` atualizado para ler variáveis do `.env`
|
||||
- ✅ `API_SUPRIMENTOS_CONFIG` criado com 3 configurações:
|
||||
- `TOKEN`: lê do `.env`
|
||||
- `DETALHE_URL`: endpoint dos pedidos em aberto
|
||||
- `IMPLANTACAO_URL`: endpoint de implantação
|
||||
|
||||
**Como funciona:**
|
||||
```python
|
||||
from django.conf import settings
|
||||
api_config = settings.API_SUPRIMENTOS_CONFIG
|
||||
token = api_config['TOKEN'] # Carregado do .env
|
||||
```
|
||||
|
||||
### 3. **Funções de Integração** 🔄
|
||||
|
||||
#### `get_api_suprimentos_data()`
|
||||
Busca dados da API com segurança:
|
||||
- ✅ Autenticação JWT com token do `.env`
|
||||
- ✅ Timeout de 30 segundos
|
||||
- ✅ Tratamento de erros silencioso (não quebra a página)
|
||||
- ✅ Logs detalhados para debug
|
||||
|
||||
```python
|
||||
dados_api = get_api_suprimentos_data()
|
||||
# Retorna: lista de dicts ou None
|
||||
```
|
||||
|
||||
#### `merge_api_with_sql_data(sql_data, api_data)`
|
||||
Integra dados de ambas as fontes:
|
||||
- ✅ Join por PDV + SKU
|
||||
- ✅ Mapeia `quantidade` (API) → `sugestao_analista` (SQL)
|
||||
- ✅ Adiciona flag `dados_api_presentes` (true/false)
|
||||
- ✅ Resiliente a dados faltantes
|
||||
|
||||
```python
|
||||
merged = merge_api_with_sql_data(sql_rows, api_rows)
|
||||
# Cada linha tem agora: sugestao_analista + dados_api_presentes
|
||||
```
|
||||
|
||||
#### `get_pivot_data()` - ATUALIZADA
|
||||
View melhorada que:
|
||||
- ✅ Continua buscando dados do SQL Server
|
||||
- ✅ Agora também busca dados da API
|
||||
- ✅ Faz merge automático
|
||||
- ✅ Retorna flag `api_integrated: true/false`
|
||||
- ✅ Falha graciosamente se API cair
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [...],
|
||||
"count": 1234,
|
||||
"api_integrated": true
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 Arquivos Criados/Modificados
|
||||
|
||||
| Arquivo | Status | O quê |
|
||||
|---------|--------|-------|
|
||||
| `.env` | ✅ CRIADO | Token e URLs da API |
|
||||
| `.env.example` | ✅ CRIADO | Template para documentação |
|
||||
| `.gitignore` | ✅ MODIFICADO | Adicionado `.env` |
|
||||
| `requirements.txt` | ✅ MODIFICADO | Adicionado `python-dotenv==1.0.0` |
|
||||
| `aprovacao_pedidos/settings.py` | ✅ MODIFICADO | Leitura de `.env` + config API |
|
||||
| `home/views.py` | ✅ MODIFICADO | 3 novas funções + melhorias |
|
||||
| `INTEGRACAO_API.md` | ✅ CRIADO | Documentação completa |
|
||||
| `test_api_integration.py` | ✅ CRIADO | Script de teste |
|
||||
|
||||
## 🚀 Como Usar
|
||||
|
||||
### Setup Inicial (1 vez)
|
||||
```bash
|
||||
# 1. Instalar dependências
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Verificar arquivo .env existe na raiz
|
||||
ls -la .env
|
||||
|
||||
# 3. Rodar testes de integração
|
||||
python test_api_integration.py
|
||||
```
|
||||
|
||||
### Uso Normal
|
||||
```bash
|
||||
# Servidor Django roda normalmente
|
||||
python manage.py runserver
|
||||
|
||||
# Dados são integrados automaticamente
|
||||
# Acesse: http://localhost:8000/home/
|
||||
```
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
| Aspecto | Proteção |
|
||||
|---------|----------|
|
||||
| Token armazenado | `.env` (não versionado) |
|
||||
| Exposição acidental | `.gitignore` + `.env.example` |
|
||||
| Timeout de conexão | 30 segundos |
|
||||
| Erros não quebram site | Try/catch silencioso |
|
||||
| Logs detalhados | Para debug do admin |
|
||||
|
||||
## ⚠️ Comportamento em Caso de Falhas
|
||||
|
||||
| Cenário | Comportamento |
|
||||
|---------|---|
|
||||
| API cai | Site continua funcionando com dados do SQL |
|
||||
| Token inválido | Log de erro, usa dados do SQL |
|
||||
| PDV/SKU não matcham | Campo `sugestao_analista` fica `null` |
|
||||
| Timeout na conexão | Retorna dados do SQL depois de 30s |
|
||||
| Nenhuma quantidade na API | Campo fica `null` |
|
||||
|
||||
## 📊 Exemplo de Dados
|
||||
|
||||
**Antes (SQL apenas):**
|
||||
```
|
||||
PDV: 2001
|
||||
SKU: ABC123
|
||||
Estoque: 150
|
||||
Sugestao: 30
|
||||
```
|
||||
|
||||
**Depois (SQL + API integrado):**
|
||||
```
|
||||
PDV: 2001
|
||||
SKU: ABC123
|
||||
Estoque: 150
|
||||
Sugestao: 30
|
||||
sugestao_analista: 45 ← Vem da API!
|
||||
dados_api_presentes: true
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Para verificar se tudo funciona:
|
||||
|
||||
```bash
|
||||
# Script automático
|
||||
python test_api_integration.py
|
||||
|
||||
# Teste manual no shell Django
|
||||
python manage.py shell
|
||||
>>> from home.views import get_api_suprimentos_data
|
||||
>>> dados = get_api_suprimentos_data()
|
||||
>>> print(f"Registros: {len(dados) if dados else 'None'}")
|
||||
```
|
||||
|
||||
## 📝 Próximos Passos (Opcional)
|
||||
|
||||
Se necessário, você pode:
|
||||
|
||||
1. **Adicionar cache** aos dados da API (para performance)
|
||||
```python
|
||||
from django.core.cache import cache
|
||||
dados = cache.get('api_suprimentos_data')
|
||||
```
|
||||
|
||||
2. **Adicionar endpoint de sincronização** manual
|
||||
```python
|
||||
/api/refresh-suprimentos/ # POST para atualizar dados
|
||||
```
|
||||
|
||||
3. **Adicionar auditoria** de mudanças na API
|
||||
```python
|
||||
# Log todas as quantidades sugeridas
|
||||
```
|
||||
|
||||
4. **Criar dashboard** com dados da integração
|
||||
|
||||
## 👤 Configuração por Usuário
|
||||
|
||||
Para produção, defina o token por ambiente:
|
||||
|
||||
```bash
|
||||
# No servidor/container
|
||||
export API_SUPRIMENTOS_TOKEN="seu_token_real"
|
||||
|
||||
# Ou no docker-compose.yml
|
||||
environment:
|
||||
API_SUPRIMENTOS_TOKEN: ${API_SUPRIMENTOS_TOKEN}
|
||||
```
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**P: Como atualizar o token?**
|
||||
R: Edite o arquivo `.env` e reinicie o servidor.
|
||||
|
||||
**P: O que acontece se a API cair?**
|
||||
R: Site continua funcionando normalmente com dados do SQL.
|
||||
|
||||
**P: Como ver os logs de erro?**
|
||||
R: Verifique o terminal onde o Django está rodando ou arquivo `debug.log`.
|
||||
|
||||
**P: Posso usar essa integração sem Token?**
|
||||
R: Não, o token é obrigatório (mas a integração falha graciosamente).
|
||||
|
||||
## 🎯 Checklist de Deployment
|
||||
|
||||
- [ ] Arquivo `.env` criado com token válido
|
||||
- [ ] `.env` está no `.gitignore`
|
||||
- [ ] Dependências instaladas: `pip install -r requirements.txt`
|
||||
- [ ] Teste local passou: `python test_api_integration.py`
|
||||
- [ ] Token está protegido (não em código)
|
||||
- [ ] Logs configurados para capturar erros
|
||||
- [ ] URL da API está correta
|
||||
193
INTEGRACAO_API.md
Normal file
193
INTEGRACAO_API.md
Normal file
@ -0,0 +1,193 @@
|
||||
# Guia de Integração da API de Suprimentos
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
Este projeto integra dados da **API de Suprimentos** com os dados do **SQL Server**. A integração faz um join automático por PDV e SKU, mapeando a quantidade da API para o campo "sugestão Analista".
|
||||
|
||||
## 🔐 Configuração Segura do Token
|
||||
|
||||
### 1. Arquivo `.env` (NUNCA commit ao Git!)
|
||||
|
||||
O token da API é armazenado de forma segura em um arquivo `.env` na raiz do projeto:
|
||||
|
||||
```
|
||||
API_SUPRIMENTOS_TOKEN=seu_token_jwt_aqui
|
||||
API_SUPRIMENTOS_DETALHE_URL=https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente
|
||||
API_SUPRIMENTOS_IMPLANTACAO_URL=https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000
|
||||
```
|
||||
|
||||
### 2. Protegendo o Token
|
||||
|
||||
**IMPORTANTE:** Adicione o arquivo `.env` ao `.gitignore` para nunca expor o token:
|
||||
|
||||
```bash
|
||||
# .gitignore
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
db.sqlite3
|
||||
```
|
||||
|
||||
### 3. Usando o Token em Produção
|
||||
|
||||
Para produção, configure as variáveis de ambiente no servidor/container:
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
environment:
|
||||
API_SUPRIMENTOS_TOKEN: ${API_SUPRIMENTOS_TOKEN}
|
||||
|
||||
# Servidor Linux
|
||||
export API_SUPRIMENTOS_TOKEN="seu_token_aqui"
|
||||
```
|
||||
|
||||
## 🔄 Como Funciona a Integração
|
||||
|
||||
### Fluxo de Dados
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SQL Server │
|
||||
│ (estoque_mar_historico + draft_historico) │
|
||||
│ - PDV, SKU, Estoque, Trânsito, etc. │
|
||||
└────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ views.get_pivot_data() │
|
||||
│ - Busca dados SQL │
|
||||
│ - Busca dados API │
|
||||
│ - Faz JOIN │
|
||||
└────────────────┬───────┘
|
||||
│
|
||||
┌────────────────────────┐
|
||||
│ API de Suprimentos │
|
||||
│ (com autenticação JWT) │
|
||||
│ - PDV, SKU, quantidade │
|
||||
└────────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ Dados Mesclados │
|
||||
│ sugestao_analista = │
|
||||
│ quantidade (da API) │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### Funções Principais
|
||||
|
||||
#### 1. `get_api_suprimentos_data()`
|
||||
- Busca dados da API com token JWT
|
||||
- Retorna `None` se falhar (não quebra a página)
|
||||
- Timeout de 30 segundos
|
||||
|
||||
#### 2. `merge_api_with_sql_data()`
|
||||
- Faz JOIN por (PDV, SKU)
|
||||
- Mapeia `quantidade` → `sugestao_analista`
|
||||
- Adiciona campo `dados_api_presentes` (true/false)
|
||||
|
||||
#### 3. `get_pivot_data()`
|
||||
- Combinação de dados SQL + API
|
||||
- Retorna `api_integrated: true` se dados foram mesclados
|
||||
|
||||
## 📊 Campos Mapeados
|
||||
|
||||
| Tabela SQL | Campo da API | Campo Resultante |
|
||||
|-----------|-------------|-----------------|
|
||||
| PDV | PDV | PDV (chave join) |
|
||||
| SKU | SKU | SKU (chave join) |
|
||||
| - | quantidade | sugestao_analista |
|
||||
|
||||
## 🚀 Como Usar
|
||||
|
||||
### Setup Inicial
|
||||
|
||||
```bash
|
||||
# 1. Instalar dependências
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Criar arquivo .env
|
||||
cp .env.example .env
|
||||
# Editar .env e adicionar seu token JWT
|
||||
|
||||
# 3. Executar o servidor
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
### Na Página
|
||||
|
||||
Os dados serão automaticamente mesclados ao carregar a tabela. Você verá:
|
||||
|
||||
- **Quando API está disponível**:
|
||||
- Coluna `sugestao_analista` preenchida com dados da API
|
||||
- Log no console: "Dados da API integrados: X registros"
|
||||
|
||||
- **Quando API falha ou token inválido**:
|
||||
- Dados do SQL Server continuam aparecendo
|
||||
- Log de erro aparece no terminal
|
||||
- Página não quebra
|
||||
|
||||
## ⚠️ Tratamento de Erros
|
||||
|
||||
A integração é **resiliente**:
|
||||
|
||||
✅ Se a API cair → página continua funcionando com dados do SQL
|
||||
✅ Se o token expirar → backend usa dados do SQL
|
||||
✅ Se PDV/SKU não matcham → campo fica `null`
|
||||
|
||||
## 🔍 Debug
|
||||
|
||||
### Ver logs da integração
|
||||
|
||||
```python
|
||||
# No terminal do servidor
|
||||
[2024-XX-XX XX:XX:XX] Dados da API integrados: 250 registros
|
||||
[2024-XX-XX XX:XX:XX] Erro ao chamar API: Connection timeout
|
||||
```
|
||||
|
||||
### Testar a integração manualmente
|
||||
|
||||
```python
|
||||
# manage.py shell
|
||||
from home.views import get_api_suprimentos_data
|
||||
dados = get_api_suprimentos_data()
|
||||
print(f"Registros obtidos: {len(dados) if dados else 'None'}")
|
||||
```
|
||||
|
||||
## 📝 Exemplo de Resposta
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"PDV": "2001",
|
||||
"Descricao_PDV": "Loja Centro",
|
||||
"SKU": "ABC123",
|
||||
"Estoque_Total": 150,
|
||||
"sugestao_analista": 45,
|
||||
"dados_api_presentes": true,
|
||||
...
|
||||
}
|
||||
],
|
||||
"api_integrated": true
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ Segurança
|
||||
|
||||
- ✅ Token armazenado em `.env` (não no código)
|
||||
- ✅ Arquivo `.env` no `.gitignore`
|
||||
- ✅ Timeout de conexão (30s)
|
||||
- ✅ Tratamento de erros silencioso
|
||||
- ✅ Logs detalhados para debug
|
||||
- ✅ Sem exposição de senhas no código
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Se encontrar problemas:
|
||||
|
||||
1. Verifique se o `.env` existe na raiz do projeto
|
||||
2. Confirme que o token JWT é válido
|
||||
3. Verifique os logs no terminal do servidor
|
||||
4. Teste manualmente com: `python manage.py shell`
|
||||
106
QUICKSTART.md
Normal file
106
QUICKSTART.md
Normal file
@ -0,0 +1,106 @@
|
||||
# ⚡ Quick Start - Projeto Aprovação Pedidos
|
||||
|
||||
## 1️⃣ Instalação Rápida (2 min)
|
||||
|
||||
```bash
|
||||
# Instalar dependências
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Criar banco de dados
|
||||
python manage.py migrate
|
||||
|
||||
# Rodar o servidor
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**Acesse:** http://localhost:8000
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ Com Docker (3 min)
|
||||
|
||||
```bash
|
||||
# Build e rodar
|
||||
docker-compose up --build
|
||||
|
||||
# Acesse em background (Ctrl+C para parar)
|
||||
```
|
||||
|
||||
**Acesse:** http://localhost:8000
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ Criar Superusuário (Admin)
|
||||
|
||||
```bash
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Acesse: http://localhost:8000/admin/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ Estrutura de Arquivos Importante
|
||||
|
||||
```
|
||||
├── home/templates/home/home_page.html ← Edite aqui o HTML/CSS
|
||||
├── home/views.py ← Edite aqui a lógica
|
||||
├── aprovacao_pedidos/settings.py ← Configurações Django
|
||||
├── manage.py ← Comandos Django
|
||||
└── requirements.txt ← Dependências
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ Comandos Úteis
|
||||
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `python manage.py runserver` | Inicia servidor dev |
|
||||
| `python manage.py migrate` | Aplica migrações BD |
|
||||
| `python manage.py makemigrations` | Cria migrações |
|
||||
| `python manage.py createsuperuser` | Cria admin |
|
||||
| `python manage.py collectstatic` | Coleta arquivos estáticos |
|
||||
| `docker-compose up` | Rodar com Docker |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Customizar Layout
|
||||
|
||||
Edit: `home/templates/home/home_page.html`
|
||||
|
||||
### Alterar cores principais:
|
||||
```css
|
||||
:root {
|
||||
--accent: #03506B; /* Mude aqui */
|
||||
}
|
||||
```
|
||||
|
||||
### Adicionar novo card:
|
||||
```html
|
||||
<div class="card">
|
||||
<p class="card-title">Meu Card</p>
|
||||
<p class="card-value">100</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Próximas Etapas
|
||||
|
||||
1. ✅ Rodar localmente
|
||||
2. 📝 Customizar para suas necessidades
|
||||
3. 🔌 Conectar APIs reais
|
||||
4. 🚢 Deploy (Docker/servidor)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Problemas?
|
||||
|
||||
- **Porta 8000 já em uso?** → `python manage.py runserver 8001`
|
||||
- **Módulo não encontrado?** → `pip install -r requirements.txt`
|
||||
- **Banco de dados corrompido?** → Delete `db.sqlite3` e rode `migrate` novamente
|
||||
|
||||
---
|
||||
|
||||
**Bom desenvolvimento! 🚀**
|
||||
239
README.md
Normal file
239
README.md
Normal file
@ -0,0 +1,239 @@
|
||||
# Projeto Aprovação Pedidos - Ginseng
|
||||
|
||||
Este é um projeto Django com a estética visual padronizada de todos os apps da empresa Ginseng. Contém um app `home` com um dashboard completo.
|
||||
|
||||
## 📋 Estrutura do Projeto
|
||||
|
||||
```
|
||||
projeto-aprovação-pedidos/
|
||||
├── aprovacao_pedidos/ # Configuração principal do Django
|
||||
│ ├── settings.py # Configurações do Django
|
||||
│ ├── urls.py # URLs principais
|
||||
│ ├── wsgi.py # WSGI para deploy
|
||||
│ ├── asgi.py # ASGI para deploy
|
||||
│ └── middleware.py # Middlewares customizados
|
||||
├── home/ # App principal com dashboard
|
||||
│ ├── templates/home/ # Templates HTML
|
||||
│ ├── views.py # Views
|
||||
│ ├── urls.py # URLs do app
|
||||
│ └── migrations/ # Migrações de banco de dados
|
||||
├── manage.py # Gerenciador do Django
|
||||
├── requirements.txt # Dependências Python
|
||||
├── Dockerfile # Configuração Docker
|
||||
└── .dockerignore # Arquivos ignorados no Docker
|
||||
```
|
||||
|
||||
## 🚀 Como Rodar Localmente
|
||||
|
||||
### Pré-requisitos
|
||||
- Python 3.11+
|
||||
- pip (gerenciador de pacotes Python)
|
||||
- Git (opcional)
|
||||
|
||||
### Instalação Rápida
|
||||
|
||||
1. **Clone ou copie o projeto para sua máquina**
|
||||
|
||||
2. **Instale as dependências:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Execute as migrações do banco de dados:**
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
4. **Crie um superusuário (opcional, para acessar admin):**
|
||||
```bash
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
5. **Inicie o servidor de desenvolvimento:**
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
6. **Acesse a aplicação:**
|
||||
- **Home:** http://localhost:8000/
|
||||
- **Admin:** http://localhost:8000/admin/
|
||||
|
||||
## 🐳 Como Rodar com Docker
|
||||
|
||||
### Pré-requisitos
|
||||
- Docker instalado e rodando
|
||||
|
||||
### Passos
|
||||
|
||||
1. **Build da imagem Docker:**
|
||||
```bash
|
||||
docker build -t projeto-aprovacao-pedidos .
|
||||
```
|
||||
|
||||
2. **Execute o container:**
|
||||
```bash
|
||||
docker run -p 8000:8000 projeto-aprovacao-pedidos
|
||||
```
|
||||
|
||||
3. **Acesse a aplicação:**
|
||||
```
|
||||
http://localhost:8000/
|
||||
```
|
||||
|
||||
### Com Docker Compose (Recomendado)
|
||||
|
||||
Crie um arquivo `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DEBUG=True
|
||||
- PYTHONUNBUFFERED=1
|
||||
volumes:
|
||||
- .:/app
|
||||
```
|
||||
|
||||
Depois execute:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## 📱 Features Implementadas
|
||||
|
||||
✅ **Dashboard Responsivo**
|
||||
- 4 cards informativos
|
||||
- Gráfico comparativo com Chart.js
|
||||
- Layout que se adapta a todos os tamanhos de tela
|
||||
|
||||
✅ **Sidebar Colapsível**
|
||||
- Navegação intuitiva
|
||||
- Menu fixo no lado esquerdo
|
||||
- Efeito de backdrop ao abrir
|
||||
|
||||
✅ **Painel de Filtros**
|
||||
- Desliza pela direita da tela
|
||||
- Filtros customizáveis
|
||||
- Opção para limpar todos os filtros
|
||||
|
||||
✅ **Header com Logo**
|
||||
- Logo Ginseng oficial
|
||||
- Ciclo ativo exibido
|
||||
- Botões de ação rápida
|
||||
|
||||
✅ **Design Moderno**
|
||||
- Cores padronizadas da empresa (#03506B)
|
||||
- Tipografia clean (Segoe UI)
|
||||
- Sombras e transições suaves
|
||||
|
||||
## 🎨 Personalizando a Página
|
||||
|
||||
### Alterar as Cores Principais
|
||||
Edit o arquivo [home/templates/home/home_page.html](home/templates/home/home_page.html) e modifique as CSS variables no `<style>`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: #03506B; /* Cor principal */
|
||||
--accent-light: #046b8f; /* Cor hover */
|
||||
--bg-color: #f6f8fb; /* Fundo */
|
||||
--text-primary: #111; /* Texto principal */
|
||||
--text-secondary: #6b7280; /* Texto secundário */
|
||||
}
|
||||
```
|
||||
|
||||
### Adicionar Novos Cards
|
||||
No template HTML, dentro da seção `.cards-row`, adicione:
|
||||
|
||||
```html
|
||||
<div class="card">
|
||||
<p class="card-title">Seu título</p>
|
||||
<p class="card-value" id="seuId">—</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Depois, adicione os dados no JavaScript:
|
||||
```javascript
|
||||
document.getElementById('seuId').textContent = 'Seu valor';
|
||||
```
|
||||
|
||||
## 📊 Integrando com APIs Reais
|
||||
|
||||
Para conectar a dados reais, modifique o arquivo [home/views.py](home/views.py):
|
||||
|
||||
```python
|
||||
@login_required(login_url='/login/')
|
||||
def home_page(request):
|
||||
"""Nova página home com estética consistente do projeto."""
|
||||
# Fetch dados da API
|
||||
response = requests.get('https://sua-api.com/dados')
|
||||
dados = response.json()
|
||||
|
||||
context = {
|
||||
'dados': dados,
|
||||
'user_email': request.user.email if request.user.is_authenticated else '',
|
||||
}
|
||||
return render(request, "home/home_page.html", context)
|
||||
```
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
- Django CSRF protection ativada
|
||||
- Configuração segura para produção (altere `DEBUG=False` em settings.py)
|
||||
- Variáveis sensíveis em .env (recomendado usar python-decouple)
|
||||
|
||||
## 📚 Adicionar Dependências
|
||||
|
||||
Para instalar novos pacotes:
|
||||
|
||||
```bash
|
||||
pip install nome-do-pacote
|
||||
pip freeze > requirements.txt
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Erro "ModuleNotFoundError"
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Porta 8000 já em uso
|
||||
```bash
|
||||
python manage.py runserver 8001
|
||||
```
|
||||
|
||||
### Limpar cache e arquivos compilados
|
||||
```bash
|
||||
find . -type d -name __pycache__ -exec rm -r {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
```
|
||||
|
||||
## 📝 Próximos Passos
|
||||
|
||||
1. Criar superusuário para o admin
|
||||
2. Integrar com suas APIs de dados
|
||||
3. Adicionar novos apps Django conforme necessário
|
||||
4. Configurar autenticação (se necessário)
|
||||
5. Deploy em servidor (AWS, Heroku, etc.)
|
||||
|
||||
## 🤝 Contribuindo
|
||||
|
||||
Para fazer mudanças mantendo a estética:
|
||||
|
||||
1. Mantenha as cores padronizadas
|
||||
2. Use os componentes existentes (cards, buttons, etc.)
|
||||
3. Siga a estrutura de pastas do projeto
|
||||
4. Documente mudanças significativas
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para dúvidas ou problemas, entre em contato com o time de desenvolvimento.
|
||||
|
||||
---
|
||||
|
||||
**© 2026 Ginseng Pedidos. Todos os direitos reservados.**
|
||||
0
aprovacao_pedidos/__init__.py
Normal file
0
aprovacao_pedidos/__init__.py
Normal file
16
aprovacao_pedidos/asgi.py
Normal file
16
aprovacao_pedidos/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for aprovacao_pedidos project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'aprovacao_pedidos.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
35
aprovacao_pedidos/middleware.py
Normal file
35
aprovacao_pedidos/middleware.py
Normal file
@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogIPMiddleware:
|
||||
"""Middleware para logar o IP de cada requisição"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Obtém o IP do cliente
|
||||
ip = self.get_client_ip(request)
|
||||
|
||||
# Loga o IP da requisição
|
||||
print(f"Request from IP: {ip}")
|
||||
logger.info(f"Request from IP: {ip} - Method: {request.method} - Path: {request.path}")
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""
|
||||
Obtém o IP real do cliente, considerando proxies
|
||||
"""
|
||||
# Verifica se há X-Forwarded-For (quando atrás de proxy/load balancer)
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
# Caso contrário, usa REMOTE_ADDR
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
return ip
|
||||
192
aprovacao_pedidos/settings.py
Normal file
192
aprovacao_pedidos/settings.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Django settings for aprovacao_pedidos project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Carregar variáveis de ambiente do arquivo .env
|
||||
load_dotenv()
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
if not SECRET_KEY and not DEBUG:
|
||||
raise ImproperlyConfigured("SECRET_KEY obrigatoria quando DEBUG=False.")
|
||||
if not SECRET_KEY:
|
||||
SECRET_KEY = "dev-only-secret-key-change-me"
|
||||
|
||||
# Definir ALLOWED_HOSTS baseado no ambiente
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"home",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'aprovacao_pedidos.middleware.LogIPMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'aprovacao_pedidos.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'aprovacao_pedidos.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
# SQLite para desenvolvimento
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# SQL Server (comentado - descomente após instalar django-pyodbc-azure)
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'mssql',
|
||||
# 'NAME': 'your_database_name', # MODIFICAR: seu nome do banco
|
||||
# 'USER': 'your_user', # MODIFICAR: seu usuário
|
||||
# 'PASSWORD': 'your_password', # MODIFICAR: sua senha
|
||||
# 'HOST': '10.77.77.10', # MODIFICAR: seu servidor SQL Server
|
||||
# 'PORT': '1433',
|
||||
# 'OPTIONS': {
|
||||
# 'driver': 'ODBC Driver 17 for SQL Server',
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# Configuração SQL Server para queries raw (sem ORM) - carregada do .env
|
||||
SQL_SERVER_CONFIG = {
|
||||
'SERVER': os.getenv('SQL_SERVER_HOST', '10.77.77.10'),
|
||||
'DATABASE': os.getenv('SQL_SERVER_DATABASE', 'GINSENG'),
|
||||
'USERNAME': os.getenv('SQL_SERVER_USERNAME', 'suprimentos'),
|
||||
'PASSWORD': os.getenv('SQL_SERVER_PASSWORD', ''),
|
||||
}
|
||||
|
||||
# Configuração da API de Suprimentos (carregada do .env)
|
||||
API_SUPRIMENTOS_CONFIG = {
|
||||
'TOKEN': os.getenv('API_SUPRIMENTOS_TOKEN', ''),
|
||||
'DETALHE_URL': os.getenv('API_SUPRIMENTOS_DETALHE_URL', 'https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=pendente'),
|
||||
'IMPLANTACAO_URL': os.getenv('API_SUPRIMENTOS_IMPLANTACAO_URL', 'https://api.grupoginseng.com.br/api/vw_suprimentos_implantacaopedido?limit=50000'),
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'pt-br'
|
||||
|
||||
TIME_ZONE = 'America/Sao_Paulo'
|
||||
|
||||
USE_I18N = False
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# CSRF e segurança
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000,http://127.0.0.1:8000').split(',')
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
SECURE_REFERRER_POLICY = 'same-origin'
|
||||
|
||||
# Quando o app estiver atrás de proxy reverso/TLS terminando no proxy.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_SSL_REDIRECT = os.getenv('SECURE_SSL_REDIRECT', 'False').lower() == 'true'
|
||||
SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', '0'))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv('SECURE_HSTS_INCLUDE_SUBDOMAINS', 'False').lower() == 'true'
|
||||
SECURE_HSTS_PRELOAD = os.getenv('SECURE_HSTS_PRELOAD', 'False').lower() == 'true'
|
||||
|
||||
# Login redirect
|
||||
LOGIN_URL = '/'
|
||||
LOGIN_REDIRECT_URL = '/home/'
|
||||
21
aprovacao_pedidos/urls.py
Normal file
21
aprovacao_pedidos/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""
|
||||
URL configuration for aprovacao_pedidos project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from home import views as home_views
|
||||
from django.http import HttpResponse
|
||||
|
||||
def healthz(request):
|
||||
"""Health check endpoint"""
|
||||
return HttpResponse(status=200)
|
||||
|
||||
urlpatterns = [
|
||||
path('healthz', healthz, name='healthz'),
|
||||
path('admin/', admin.site.urls),
|
||||
path('home/', include('home.urls')),
|
||||
path('', home_views.home_page, name='home'),
|
||||
]
|
||||
16
aprovacao_pedidos/wsgi.py
Normal file
16
aprovacao_pedidos/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for aprovacao_pedidos project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'aprovacao_pedidos.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: ${APP_CONTAINER_NAME:-aprovacao-pedidos-novo}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-8010}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DEBUG=${DEBUG:-False}
|
||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
# volumes removidos para produção - descomente apenas em desenvolvimento
|
||||
# volumes:
|
||||
# - .:/app
|
||||
stdin_open: true
|
||||
tty: true
|
||||
11
entrypoint.sh
Normal file
11
entrypoint.sh
Normal file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
echo "Aplicando migracoes..."
|
||||
python manage.py migrate --noinput
|
||||
|
||||
echo "Coletando arquivos estaticos..."
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
echo "Iniciando Gunicorn..."
|
||||
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --timeout 60 aprovacao_pedidos.wsgi:application
|
||||
16
generate_secret_key.py
Normal file
16
generate_secret_key.py
Normal file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script para gerar SECRET_KEY segura para Django
|
||||
Execute: python generate_secret_key.py
|
||||
"""
|
||||
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
if __name__ == '__main__':
|
||||
secret_key = get_random_secret_key()
|
||||
print("\n" + "="*60)
|
||||
print("🔐 SECRET_KEY SEGURA GERADA")
|
||||
print("="*60)
|
||||
print(f"\nCopie e cole no seu arquivo .env:\n")
|
||||
print(f"SECRET_KEY={secret_key}\n")
|
||||
print("="*60 + "\n")
|
||||
0
home/__init__.py
Normal file
0
home/__init__.py
Normal file
3
home/admin.py
Normal file
3
home/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
home/apps.py
Normal file
6
home/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'home'
|
||||
0
home/migrations/__init__.py
Normal file
0
home/migrations/__init__.py
Normal file
3
home/models.py
Normal file
3
home/models.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
1567
home/templates/home/home_page.html
Normal file
1567
home/templates/home/home_page.html
Normal file
File diff suppressed because it is too large
Load Diff
3
home/tests.py
Normal file
3
home/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
home/urls.py
Normal file
10
home/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "home"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home_page, name="home_page"),
|
||||
path("api/table-data/", views.get_table_data, name="table_data"),
|
||||
path("api/pivot-data/", views.get_pivot_data, name="pivot_data"),
|
||||
]
|
||||
822
home/views.py
Normal file
822
home/views.py
Normal file
@ -0,0 +1,822 @@
|
||||
from django.shortcuts import render
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
import logging
|
||||
import pyodbc
|
||||
import requests
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_connection():
|
||||
sql_config = settings.SQL_SERVER_CONFIG
|
||||
connection_string = (
|
||||
f"Driver={{SQL Server}};"
|
||||
f"Server={sql_config['SERVER']},1433;"
|
||||
f"Database={sql_config['DATABASE']};"
|
||||
f"UID={sql_config['USERNAME']};"
|
||||
f"PWD={sql_config['PASSWORD']}"
|
||||
)
|
||||
return pyodbc.connect(connection_string)
|
||||
|
||||
|
||||
def home_page(request):
|
||||
"""Nova página home com estética consistente do projeto."""
|
||||
context = {
|
||||
'user_email': request.user.email if request.user.is_authenticated else '',
|
||||
'is_admin': request.user.is_staff if request.user.is_authenticated else False,
|
||||
}
|
||||
return render(request, "home/home_page.html", context)
|
||||
|
||||
|
||||
def get_table_data(request):
|
||||
"""API endpoint que retorna os dados da query em formato JSON."""
|
||||
try:
|
||||
query = """
|
||||
WITH draft as (
|
||||
SELECT
|
||||
dh.data,
|
||||
dh.businessunit AS MARCA,
|
||||
dh.loja_id AS PDV,
|
||||
dh.code AS SKU,
|
||||
dh.description AS DESCRICAO_PRODUTO,
|
||||
dh.codcategory AS CATEGORIA,
|
||||
dh.stock_actual AS ESTOQUE,
|
||||
dh.pendingorder AS PENDENTE,
|
||||
dh.stock_intransit AS Transito,
|
||||
dh.dayswithoutsales AS Dias_Sem_Vendas,
|
||||
dh.nextcycleprojection AS Projecao_Ciclo,
|
||||
dh.secondtonextcycleprojection AS Projecao_ProxCiclo,
|
||||
dh.thirdtolastcyclesales AS Vendas_C2,
|
||||
dh.secondtolastcyclesales AS Vendas_C1,
|
||||
dh.currentcyclesales AS Vendas_Atual,
|
||||
dh.pricesellin AS Preco_compra,
|
||||
dh.salescurve AS CLASSE,
|
||||
dh.purchasesuggestion AS sugestao_Compra,
|
||||
NULL AS [DDV PREVISTO],
|
||||
CASE
|
||||
WHEN dh.promotions_description = '' THEN 'REGULAR'
|
||||
ELSE 'PROMOCAO'
|
||||
END AS CAMPANHA,
|
||||
CASE
|
||||
WHEN dh.isproductdeactivated = 0 THEN 'ATIVO'
|
||||
ELSE 'DESATIVADO'
|
||||
END AS STATUS_PRODUTO
|
||||
FROM draft_historico dh with(nolock)
|
||||
WHERE dh.data = (SELECT MAX(data) FROM draft_historico))
|
||||
SELECT
|
||||
CASE
|
||||
WHEN emh.DESCRICAO LIKE '%OUI%'
|
||||
THEN 'OUI'
|
||||
ELSE emh.ORIGEM
|
||||
END AS MARCA,
|
||||
pa.MESORREGIAO,
|
||||
pa.[DESCRIÇÃO] as Descricao_PDV,
|
||||
pa.UF,
|
||||
pa.CANAL,
|
||||
emh.data_estoque,
|
||||
emh.PDV,
|
||||
emh.SKU,
|
||||
emh.SKU_PARA,
|
||||
emh.DESCRICAO,
|
||||
emh.CATEGORIA,
|
||||
emh.CLASSE,
|
||||
emh.[FASES PRODUTO],
|
||||
emh.[ESTOQUE ATUAL],
|
||||
emh.[DDV PREVISTO],
|
||||
emh.[ESTOQUE EM TRANSITO],
|
||||
emh.[PEDIDO PENDENTE],
|
||||
D.VENDAS_ATUAL,
|
||||
D.Projecao_Ciclo,
|
||||
D.Projecao_Ciclo - D.VENDAS_ATUAL AS DIF_PROJE_VS_VENDA,
|
||||
D.sugestao_Compra,
|
||||
D.Preco_compra
|
||||
FROM estoque_mar_historico emh with(nolock)
|
||||
LEFT JOIN pdvs_att pa
|
||||
ON pa.PDV_PARA = emh.PDV
|
||||
LEFT JOIN draft d on d.PDV= emh.PDV and d.SKU = emh.SKU
|
||||
WHERE emh.data_estoque = (select max(data_estoque) from estoque_mar_historico with(nolock)) and emh.CATEGORIA not in ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||
AND emh.pdv not in ('23703')
|
||||
ORDER BY pa.[DESCRIÇÃO], emh.CLASSE
|
||||
"""
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': rows,
|
||||
'count': len(rows)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Retornar erro mas não quebrar a página
|
||||
logger.exception("Erro ao carregar dados")
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'data': [] # Retornar array vazio para não quebrar frontend
|
||||
}, status=200) # Retornar 200 para não gerar erro no console
|
||||
|
||||
|
||||
# ===== INTEGRAÇÃO COM API DE SUPRIMENTOS =====
|
||||
|
||||
def get_api_suprimentos_data(ciclo: str = '', idpedido: str = '', tipo: str = '') -> Optional[List[Dict]]:
|
||||
"""
|
||||
Busca dados das APIs de Suprimentos e faz INNER JOIN por idpedido.
|
||||
Retorna apenas registros presentes simultaneamente em:
|
||||
- API_SUPRIMENTOS_IMPLANTACAO_URL
|
||||
- API_SUPRIMENTOS_DETALHE_URL (em aberto/pendente)
|
||||
"""
|
||||
try:
|
||||
ciclo_norm = str(ciclo or '').strip()
|
||||
tipo_norm = str(tipo or '').strip().lower()
|
||||
idpedido_norm = ','.join(
|
||||
sorted({p.strip() for p in str(idpedido or '').replace(';', ',').split(',') if p.strip()})
|
||||
)
|
||||
cache_key = f"api_suprimentos_join:v2:ciclo={ciclo_norm}:tipo={tipo_norm}:idpedido={idpedido_norm}"
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
api_config = settings.API_SUPRIMENTOS_CONFIG
|
||||
token = api_config['TOKEN']
|
||||
|
||||
if not token:
|
||||
logger.warning("Token da API de Suprimentos não configurado no .env")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def _append_query_params(url: str, params: Dict[str, str]) -> str:
|
||||
parsed = urlparse(url)
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
for key, value in params.items():
|
||||
if value:
|
||||
query[key] = value
|
||||
return urlunparse(parsed._replace(query=urlencode(query)))
|
||||
|
||||
def _fetch_rows(url: str) -> Optional[List[Dict]]:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Erro ao chamar API {url}: {response.status_code}")
|
||||
return None
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and 'data' in payload:
|
||||
return payload['data']
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return None
|
||||
|
||||
detalhe_url = _append_query_params(
|
||||
api_config['DETALHE_URL'],
|
||||
{'ciclo': ciclo}
|
||||
)
|
||||
implantacao_rows = _fetch_rows(api_config['IMPLANTACAO_URL'])
|
||||
detalhe_rows = _fetch_rows(detalhe_url)
|
||||
if implantacao_rows is None or detalhe_rows is None:
|
||||
return None
|
||||
|
||||
def _get_idpedido(row: Dict) -> str:
|
||||
return str(
|
||||
row.get('idpedido', row.get('idPedido', row.get('IDPEDIDO', '')))
|
||||
).strip()
|
||||
|
||||
idpedidos_filtro = set(idpedido_norm.split(',')) if idpedido_norm else set()
|
||||
tipo_filtro = tipo_norm
|
||||
|
||||
# Índice da API de detalhe (abertos) por idpedido
|
||||
detalhe_by_id = {}
|
||||
for row in detalhe_rows:
|
||||
idpedido = _get_idpedido(row)
|
||||
tipo_row = _get_api_tipo(row).lower()
|
||||
if tipo_filtro and tipo_row != tipo_filtro:
|
||||
continue
|
||||
if idpedidos_filtro and idpedido not in idpedidos_filtro:
|
||||
continue
|
||||
if idpedido:
|
||||
detalhe_by_id[idpedido] = row
|
||||
|
||||
# INNER JOIN por idpedido: mantém somente implantação que existe na detalhe
|
||||
joined_rows = []
|
||||
for row in implantacao_rows:
|
||||
idpedido = _get_idpedido(row)
|
||||
if idpedido and idpedido in detalhe_by_id:
|
||||
detalhe_row = detalhe_by_id[idpedido]
|
||||
merged_row = dict(row)
|
||||
ciclo_val = _get_api_ciclo(detalhe_row)
|
||||
if ciclo_val and not _get_api_ciclo(merged_row):
|
||||
merged_row['ciclo'] = ciclo_val
|
||||
joined_rows.append(merged_row)
|
||||
|
||||
logger.info(
|
||||
"API join por idpedido: implantacao=%s detalhe=%s inner_join=%s ciclo=%s tipo=%s idpedidos=%s",
|
||||
len(implantacao_rows), len(detalhe_rows), len(joined_rows), ciclo or "(todos)", tipo or "(todos)", len(idpedidos_filtro)
|
||||
)
|
||||
cache.set(cache_key, joined_rows, timeout=300)
|
||||
return joined_rows
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Erro de conexão com API de Suprimentos: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao processar dados da API: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_api_base_pdvs_data() -> Optional[List[Dict]]:
|
||||
"""Busca base de PDVs para enriquecer dados com CANAL e ANALISTA."""
|
||||
try:
|
||||
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||
token = api_config.get('TOKEN')
|
||||
url = 'https://api.grupoginseng.com.br/api/base_pdvs'
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
if response.status_code != 200:
|
||||
logger.error("Erro ao chamar API base_pdvs: %s", response.status_code)
|
||||
return None
|
||||
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get('data'), list):
|
||||
return payload['data']
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Erro de conexão com API base_pdvs: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Erro ao processar API base_pdvs: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_api_orcamento_saldo_por_pdv(mes: Optional[int] = None) -> Dict[str, float]:
|
||||
"""Busca saldo_pdv por PDV na API de orçamento, filtrando mês atual (ou informado)."""
|
||||
try:
|
||||
mes_alvo = int(mes or datetime.now().month)
|
||||
cache_key = f"api_orcamento_saldo_pdv:mes={mes_alvo}"
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||
token = api_config.get('TOKEN')
|
||||
url = 'https://api.grupoginseng.com.br/api/vw_orcamento_vs_notas_2026?limit=50000'
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
if response.status_code != 200:
|
||||
logger.error("Erro ao chamar API orçamento: %s", response.status_code)
|
||||
return {}
|
||||
|
||||
payload = response.json()
|
||||
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
|
||||
saldo_por_pdv: Dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
mes_row = int(_parse_float(row.get('mes')))
|
||||
except Exception:
|
||||
continue
|
||||
if mes_row != mes_alvo:
|
||||
continue
|
||||
pdv = _normalize_pdv(row.get('pdv', row.get('PDV', row.get('loja_id', row.get('LOJA_ID', '')))))
|
||||
if not pdv:
|
||||
continue
|
||||
saldo = _parse_float(row.get('saldo_pdv', row.get('SALDO_PDV', 0)))
|
||||
saldo_por_pdv[pdv] = saldo_por_pdv.get(pdv, 0.0) + saldo
|
||||
|
||||
cache.set(cache_key, saldo_por_pdv, timeout=300)
|
||||
return saldo_por_pdv
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Erro de conexão com API orçamento: %s", e)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error("Erro ao processar API orçamento: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def get_api_pendingorder_por_pdv() -> Dict[str, float]:
|
||||
"""Busca pendingorder por PDV (loja_id) na API e agrega por loja."""
|
||||
try:
|
||||
cache_key = "api_pendingorder_pdv"
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||
token = api_config.get('TOKEN')
|
||||
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_draft?limit=50000'
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
if response.status_code != 200:
|
||||
logger.error("Erro ao chamar API pendingorder: %s", response.status_code)
|
||||
return {}
|
||||
|
||||
payload = response.json()
|
||||
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
|
||||
pending_por_pdv: Dict[str, float] = {}
|
||||
for row in rows:
|
||||
pdv = _normalize_pdv(row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', '')))))
|
||||
if not pdv:
|
||||
continue
|
||||
# Nesta API o valor financeiro pendente vem em total_pricesellin.
|
||||
pending = _parse_float(
|
||||
row.get(
|
||||
'total_pricesellin',
|
||||
row.get('TOTAL_PRICESELLIN', row.get('pendingorder', row.get('PENDINGORDER', row.get('pendente', 0))))
|
||||
)
|
||||
)
|
||||
pending_por_pdv[pdv] = pending_por_pdv.get(pdv, 0.0) + pending
|
||||
|
||||
cache.set(cache_key, pending_por_pdv, timeout=300)
|
||||
return pending_por_pdv
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Erro de conexão com API pendingorder: %s", e)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error("Erro ao processar API pendingorder: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_float(value) -> float:
|
||||
"""Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56)."""
|
||||
if value in (None, ''):
|
||||
return 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
try:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
|
||||
# Mantém apenas dígitos, separadores e sinal.
|
||||
clean = ''.join(ch for ch in text if ch.isdigit() or ch in ',.-')
|
||||
if not clean or clean in ('-', '.', ',', '-.', '-,'):
|
||||
return 0.0
|
||||
|
||||
# Caso com ambos separadores: assume o último como decimal.
|
||||
if ',' in clean and '.' in clean:
|
||||
if clean.rfind(',') > clean.rfind('.'):
|
||||
# BR: 1.234,56 -> 1234.56
|
||||
clean = clean.replace('.', '').replace(',', '.')
|
||||
else:
|
||||
# EN: 1,234.56 -> 1234.56
|
||||
clean = clean.replace(',', '')
|
||||
elif ',' in clean:
|
||||
# Apenas vírgula: trata como decimal.
|
||||
clean = clean.replace('.', '').replace(',', '.')
|
||||
else:
|
||||
# Apenas ponto: remove vírgulas residuais.
|
||||
clean = clean.replace(',', '')
|
||||
|
||||
return float(clean)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _get_api_quantidade(row: Dict) -> float:
|
||||
"""Obtém quantidade da API aceitando variações de chave."""
|
||||
return _parse_float(
|
||||
row.get('quantidade', row.get('Quantidade', row.get('QUANTIDADE', 0)))
|
||||
)
|
||||
|
||||
|
||||
def _get_api_ciclo(row: Dict) -> str:
|
||||
"""Obtém ciclo da API aceitando variações de chave."""
|
||||
return str(
|
||||
row.get('ciclo', row.get('CICLO', row.get('Ciclo', '')))
|
||||
).strip()
|
||||
|
||||
|
||||
def _get_api_tipo(row: Dict) -> str:
|
||||
"""Obtém tipo da API aceitando variações de chave."""
|
||||
return str(
|
||||
row.get('tipo', row.get('TIPO', row.get('Tipo', row.get('tipo_pedido', row.get('TIPO_PEDIDO', '')))))
|
||||
).strip()
|
||||
|
||||
|
||||
def _normalize_pdv(value) -> str:
|
||||
"""Normaliza PDV para comparação entre fontes diferentes."""
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return ''
|
||||
# Remove .0 comum quando vem de conversão numérica.
|
||||
if text.endswith('.0'):
|
||||
text = text[:-2]
|
||||
# Remove zeros à esquerda para evitar mismatch 02001 vs 2001.
|
||||
text = text.lstrip('0')
|
||||
return text or '0'
|
||||
|
||||
|
||||
def _normalize_sku(value) -> str:
|
||||
"""Normaliza SKU para comparação entre fontes diferentes."""
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return ''
|
||||
if text.endswith('.0'):
|
||||
text = text[:-2]
|
||||
return text.upper()
|
||||
|
||||
|
||||
def merge_api_with_sql_data(sql_data: List[Dict], api_data: Optional[List[Dict]]) -> List[Dict]:
|
||||
"""
|
||||
Faz o join entre dados do SQL Server e da API por PDV + SKU.
|
||||
Agrega quantidade da API por (PDV, SKU) e mapeia para sugestao_analista.
|
||||
"""
|
||||
# Agregar API por (PDV, SKU) (somatório de quantidade)
|
||||
api_por_chave = {}
|
||||
for row in api_data or []:
|
||||
pdv = _normalize_pdv(row.get('pdv', row.get('PDV', '')))
|
||||
sku = _normalize_sku(row.get('sku', row.get('SKU', row.get('codigo', row.get('CODIGO', '')))))
|
||||
if not pdv or not sku:
|
||||
continue
|
||||
key = (pdv, sku)
|
||||
api_por_chave[key] = api_por_chave.get(key, 0.0) + _get_api_quantidade(row)
|
||||
|
||||
# Fazer o merge da agregação por (PDV, SKU) no resultado da pivot
|
||||
merged_data = []
|
||||
for sql_row in sql_data:
|
||||
pdv = _normalize_pdv(sql_row.get('PDV', sql_row.get('pdv', '')))
|
||||
sku = _normalize_sku(sql_row.get('SKU', sql_row.get('sku', sql_row.get('SKU_PARA', ''))))
|
||||
key = (pdv, sku)
|
||||
sugestao_analista = api_por_chave.get(key, 0.0)
|
||||
sql_row['sugestao_analista'] = sugestao_analista
|
||||
sql_row['Sugestao_Compra_Analista'] = sugestao_analista
|
||||
# Mantém Total_Compra sem incluir quantidade da API:
|
||||
# a API de implantacao alimenta apenas Sugestao_Compra_Analista.
|
||||
sql_row['Total_Compra'] = None
|
||||
sql_row['dados_api_presentes'] = key in api_por_chave
|
||||
merged_data.append(sql_row)
|
||||
|
||||
return merged_data
|
||||
|
||||
|
||||
def merge_base_pdvs_with_sql_data(sql_data: List[Dict], base_pdvs_data: Optional[List[Dict]]) -> List[Dict]:
|
||||
"""
|
||||
Enriquecer linhas SQL com CANAL e ANALISTA vindos da API base_pdvs via join por PDV.
|
||||
"""
|
||||
if not base_pdvs_data:
|
||||
return sql_data
|
||||
|
||||
base_por_pdv: Dict[str, Dict] = {}
|
||||
for row in base_pdvs_data:
|
||||
pdv = _normalize_pdv(
|
||||
row.get('PDV', row.get('pdv', row.get('loja_id', row.get('LOJA_ID', ''))))
|
||||
)
|
||||
if not pdv:
|
||||
continue
|
||||
base_por_pdv[pdv] = row
|
||||
|
||||
for sql_row in sql_data:
|
||||
pdv = _normalize_pdv(sql_row.get('PDV', sql_row.get('pdv', '')))
|
||||
base_row = base_por_pdv.get(pdv, {})
|
||||
|
||||
canal_api = (
|
||||
base_row.get('CANAL')
|
||||
or base_row.get('canal')
|
||||
or base_row.get('Canal')
|
||||
)
|
||||
analista_api = (
|
||||
base_row.get('ANALISTA')
|
||||
or base_row.get('analista')
|
||||
or base_row.get('Analista')
|
||||
)
|
||||
|
||||
if canal_api:
|
||||
sql_row['CANAL'] = canal_api
|
||||
if analista_api:
|
||||
sql_row['ANALISTA'] = analista_api
|
||||
sql_row['Analista'] = analista_api
|
||||
|
||||
return sql_data
|
||||
|
||||
|
||||
def aggregate_rows_for_dashboard(rows: List[Dict]) -> List[Dict]:
|
||||
"""Agrega linhas por PDV + CATEGORIA + CLASSE para reduzir volume no frontend."""
|
||||
grouped = {}
|
||||
numeric_fields = [
|
||||
'Estoque_Total',
|
||||
'Transito_Total',
|
||||
'Pendente_Total',
|
||||
'DDV_Previsto_Total',
|
||||
'Projecao_Total',
|
||||
'Vendas_Atual',
|
||||
'Sugestao_Compra_Total',
|
||||
'Sugestao_Compra_Analista',
|
||||
'Diff Proj x Venda',
|
||||
'Total_SKUs',
|
||||
]
|
||||
sku_sets = defaultdict(set)
|
||||
|
||||
for row in rows or []:
|
||||
key = (
|
||||
str(row.get('PDV') or '').strip(),
|
||||
str(row.get('MESORREGIAO') or '').strip(),
|
||||
str(row.get('Descricao_PDV') or '').strip(),
|
||||
str(row.get('CATEGORIA') or '').strip(),
|
||||
str(row.get('CLASSE') or '').strip(),
|
||||
)
|
||||
if key not in grouped:
|
||||
grouped[key] = {
|
||||
'PDV': row.get('PDV'),
|
||||
'MESORREGIAO': row.get('MESORREGIAO'),
|
||||
'Descricao_PDV': row.get('Descricao_PDV'),
|
||||
'UF': row.get('UF'),
|
||||
'CANAL': row.get('CANAL'),
|
||||
'CATEGORIA': row.get('CATEGORIA'),
|
||||
'CLASSE': row.get('CLASSE'),
|
||||
'data_estoque': row.get('data_estoque'),
|
||||
'Analista': row.get('Analista', row.get('ANALISTA')),
|
||||
'ANALISTA': row.get('ANALISTA', row.get('Analista')),
|
||||
'dados_api_presentes': False,
|
||||
'Total_Compra': 0.0,
|
||||
}
|
||||
for field in numeric_fields:
|
||||
grouped[key][field] = 0.0
|
||||
|
||||
target = grouped[key]
|
||||
for field in numeric_fields:
|
||||
target[field] += _parse_float(row.get(field))
|
||||
target['Total_Compra'] += (
|
||||
_parse_float(row.get('Sugestao_Compra_Analista')) * _parse_float(row.get('Preco_compra'))
|
||||
)
|
||||
|
||||
sku = _normalize_sku(row.get('SKU', row.get('sku', row.get('SKU_PARA'))))
|
||||
if sku:
|
||||
sku_sets[key].add(sku)
|
||||
target['dados_api_presentes'] = target['dados_api_presentes'] or bool(row.get('dados_api_presentes'))
|
||||
|
||||
result = []
|
||||
for key, row in grouped.items():
|
||||
ddv = _parse_float(row.get('DDV_Previsto_Total'))
|
||||
estoque = _parse_float(row.get('Estoque_Total'))
|
||||
transito = _parse_float(row.get('Transito_Total'))
|
||||
pendente = _parse_float(row.get('Pendente_Total'))
|
||||
row['Total_SKUs'] = len(sku_sets[key])
|
||||
row['Cobertura_Atual'] = round(estoque / ddv, 0) if ddv > 0 else 0
|
||||
row['Cobertura_Est_Trans'] = round((estoque + transito) / ddv, 0) if ddv > 0 else 0
|
||||
row['Cobertura_Est_Trans_Pend'] = round((estoque + transito + pendente) / ddv, 0) if ddv > 0 else 0
|
||||
result.append(row)
|
||||
|
||||
result.sort(key=lambda r: (
|
||||
str(r.get('Descricao_PDV') or '').lower(),
|
||||
str(r.get('CLASSE') or '').lower(),
|
||||
str(r.get('CATEGORIA') or '').lower(),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def merge_orcamento_with_summary_data(
|
||||
summary_rows: List[Dict],
|
||||
saldo_por_pdv: Dict[str, float],
|
||||
pending_por_pdv: Optional[Dict[str, float]] = None,
|
||||
) -> List[Dict]:
|
||||
"""Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder."""
|
||||
if not summary_rows:
|
||||
return summary_rows
|
||||
pending_por_pdv = pending_por_pdv or {}
|
||||
for row in summary_rows:
|
||||
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
||||
saldo = float(saldo_por_pdv.get(pdv, 0.0))
|
||||
pending = float(pending_por_pdv.get(pdv, 0.0))
|
||||
row['Orcamento_Bruto'] = saldo
|
||||
row['Pendente_API'] = pending
|
||||
row['Orcamento_Disponivel'] = saldo - pending
|
||||
return summary_rows
|
||||
|
||||
|
||||
def get_pivot_data(request):
|
||||
"""API endpoint que retorna dados em granularidade PDV+SKU para pivot table.
|
||||
Inclui integração com dados da API de Suprimentos."""
|
||||
try:
|
||||
ciclo_filtro = str(request.GET.get('ciclo', '') or '').strip()
|
||||
idpedido_filtro = str(request.GET.get('idpedido', '') or '').strip()
|
||||
tipo_filtro = str(request.GET.get('tipo', '') or '').strip()
|
||||
# Query com GROUP BY PDV + SKU + CATEGORIA + CLASSE para permitir
|
||||
# merge por PDV+SKU antes do agrupamento/resumo no frontend.
|
||||
query = """
|
||||
WITH max_data AS (
|
||||
SELECT MAX(data_estoque) AS data_estoque
|
||||
FROM estoque_mar_historico
|
||||
),
|
||||
draft AS (
|
||||
SELECT
|
||||
dh.data,
|
||||
dh.businessunit AS MARCA,
|
||||
dh.loja_id AS PDV,
|
||||
dh.code AS SKU,
|
||||
dh.description AS DESCRICAO_PRODUTO,
|
||||
dh.codcategory AS CATEGORIA,
|
||||
dh.stock_actual AS ESTOQUE,
|
||||
dh.pendingorder AS PENDENTE,
|
||||
dh.stock_intransit AS Transito,
|
||||
dh.dayswithoutsales AS Dias_Sem_Vendas,
|
||||
dh.nextcycleprojection AS Projecao_Ciclo,
|
||||
dh.secondtonextcycleprojection AS Projecao_ProxCiclo,
|
||||
dh.thirdtolastcyclesales AS Vendas_C2,
|
||||
dh.secondtolastcyclesales AS Vendas_C1,
|
||||
dh.currentcyclesales AS Vendas_Atual,
|
||||
dh.pricesellin AS Preco_compra,
|
||||
dh.salescurve AS CLASSE,
|
||||
dh.purchasesuggestion AS sugestao_Compra,
|
||||
CASE
|
||||
WHEN dh.promotions_description = '' THEN 'REGULAR'
|
||||
ELSE 'PROMOCAO'
|
||||
END AS CAMPANHA,
|
||||
CASE
|
||||
WHEN dh.isproductdeactivated = 0 THEN 'ATIVO'
|
||||
ELSE 'DESATIVADO'
|
||||
END AS STATUS_PRODUTO
|
||||
FROM draft_historico dh
|
||||
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
|
||||
),
|
||||
base AS (
|
||||
SELECT
|
||||
emh.*,
|
||||
-- Conversões seguras
|
||||
TRY_CAST(REPLACE(emh.[ESTOQUE ATUAL],',', '.') as float) AS ESTOQUE_ATUAL_F,
|
||||
TRY_CAST(REPLACE(emh.[ESTOQUE EM TRANSITO],',', '.') as float) AS TRANSITO_F,
|
||||
TRY_CAST(REPLACE(emh.[PEDIDO PENDENTE],',', '.') as float) AS PENDENTE_F,
|
||||
TRY_CAST(REPLACE(emh.[DDV PREVISTO],',', '.') as float) AS DDV_F
|
||||
FROM estoque_mar_historico emh
|
||||
WHERE emh.data_estoque = (SELECT MAX(data_estoque) FROM estoque_mar_historico WITH (NOLOCK))
|
||||
AND emh.CATEGORIA NOT IN ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||
AND emh.PDV NOT IN ('23703')
|
||||
)
|
||||
SELECT
|
||||
pa.PDV_PARA as PDV,
|
||||
pa.[DESCRIÇÃO] as Descricao_PDV,
|
||||
pa.MESORREGIAO,
|
||||
pa.UF,
|
||||
pa.CANAL,
|
||||
b.SKU,
|
||||
b.CATEGORIA,
|
||||
b.CLASSE,
|
||||
SUM(b.ESTOQUE_ATUAL_F) as Estoque_Total,
|
||||
SUM(b.TRANSITO_F) as Transito_Total,
|
||||
SUM(b.PENDENTE_F) as Pendente_Total,
|
||||
SUM(b.DDV_F) as DDV_Previsto_Total,
|
||||
SUM(TRY_CAST(d.Projecao_Ciclo AS FLOAT)) as Projecao_Total,
|
||||
SUM(TRY_CAST(REPLACE(d.Vendas_Atual, ',', '.') AS FLOAT)) as Vendas_Atual,
|
||||
MAX(TRY_CAST(REPLACE(d.Preco_compra, ',', '.') AS FLOAT)) as Preco_compra,
|
||||
SUM(TRY_CAST(d.sugestao_Compra AS FLOAT)) as Sugestao_Compra_Total,
|
||||
COUNT(DISTINCT b.SKU) as Total_SKUs,
|
||||
SUM(TRY_CAST(d.Projecao_Ciclo AS FLOAT)) - SUM(TRY_CAST(REPLACE(d.Vendas_Atual, ',', '.') AS FLOAT)) as 'Diff Proj x Venda',
|
||||
-- Coberturas
|
||||
CASE
|
||||
WHEN SUM(DDV_F) > 0
|
||||
THEN round(SUM(b.ESTOQUE_ATUAL_F) /
|
||||
SUM(b.DDV_F),0)
|
||||
ELSE 0
|
||||
END as Cobertura_Atual,
|
||||
CASE
|
||||
WHEN SUM(DDV_F) > 0
|
||||
THEN round((SUM(b.ESTOQUE_ATUAL_F) + SUM(b.TRANSITO_F)) / SUM(DDV_F),0)
|
||||
ELSE 0
|
||||
END as Cobertura_Est_Trans,
|
||||
CASE
|
||||
WHEN SUM(DDV_F) > 0
|
||||
THEN round((SUM(b.ESTOQUE_ATUAL_F) + SUM(b.TRANSITO_F)+ SUM(b.PENDENTE_F)) /
|
||||
SUM(DDV_F),0)
|
||||
ELSE 0
|
||||
END as Cobertura_Est_Trans_Pend,
|
||||
NULL as Sugestao_Compra_Analista,
|
||||
NULL as Total_Compra,
|
||||
b.data_estoque
|
||||
FROM base b
|
||||
JOIN max_data md
|
||||
ON b.data_estoque = md.data_estoque
|
||||
LEFT JOIN pdvs_att pa
|
||||
ON pa.PDV_PARA = b.PDV
|
||||
LEFT JOIN draft d
|
||||
ON d.PDV = b.PDV
|
||||
AND d.SKU = b.SKU
|
||||
GROUP BY
|
||||
pa.PDV_PARA,
|
||||
pa.[DESCRIÇÃO],
|
||||
pa.MESORREGIAO,
|
||||
pa.UF,
|
||||
pa.CANAL,
|
||||
b.SKU,
|
||||
b.CATEGORIA,
|
||||
b.CLASSE,
|
||||
b.data_estoque
|
||||
ORDER BY pa.[DESCRIÇÃO];
|
||||
"""
|
||||
|
||||
categorias_query = """
|
||||
SELECT DISTINCT emh.CATEGORIA
|
||||
FROM estoque_mar_historico emh
|
||||
WHERE emh.data_estoque = (SELECT MAX(data_estoque) FROM estoque_mar_historico WITH (NOLOCK))
|
||||
AND emh.CATEGORIA NOT IN ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||
AND emh.PDV NOT IN ('23703')
|
||||
AND emh.CATEGORIA IS NOT NULL
|
||||
ORDER BY emh.CATEGORIA
|
||||
"""
|
||||
|
||||
classes_query = """
|
||||
SELECT DISTINCT emh.CLASSE
|
||||
FROM estoque_mar_historico emh
|
||||
WHERE emh.data_estoque = (SELECT MAX(data_estoque) FROM estoque_mar_historico WITH (NOLOCK))
|
||||
AND emh.CATEGORIA NOT IN ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||
AND emh.PDV NOT IN ('23703')
|
||||
AND emh.CLASSE IS NOT NULL
|
||||
ORDER BY emh.CLASSE
|
||||
"""
|
||||
|
||||
# Buscar dados do SQL Server
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
cursor.execute(categorias_query)
|
||||
categorias = [str(row[0]).strip() for row in cursor.fetchall() if row and row[0]]
|
||||
cursor.execute(classes_query)
|
||||
classes = [str(row[0]).strip() for row in cursor.fetchall() if row and row[0]]
|
||||
|
||||
# Enriquecimento com base_pdvs (CANAL/ANALISTA)
|
||||
base_pdvs_data = get_api_base_pdvs_data()
|
||||
rows = merge_base_pdvs_with_sql_data(rows, base_pdvs_data)
|
||||
|
||||
# Tentar buscar dados da API de suprimentos (não falha se indisponível)
|
||||
api_data = get_api_suprimentos_data(ciclo=ciclo_filtro, idpedido=idpedido_filtro, tipo=tipo_filtro)
|
||||
ciclos = sorted({
|
||||
_get_api_ciclo(row) for row in (api_data or []) if _get_api_ciclo(row)
|
||||
})
|
||||
tipos = sorted({
|
||||
_get_api_tipo(row) for row in (api_data or []) if _get_api_tipo(row)
|
||||
})
|
||||
|
||||
# Fazer merge dos dados se API estiver disponível
|
||||
if api_data:
|
||||
rows = merge_api_with_sql_data(rows, api_data)
|
||||
logger.info(f"Dados da API integrados: {len(api_data)} registros")
|
||||
else:
|
||||
logger.info("Dados da API não disponíveis, retornando apenas dados do SQL Server")
|
||||
summary_rows = aggregate_rows_for_dashboard(rows)
|
||||
saldo_orcamento_por_pdv = get_api_orcamento_saldo_por_pdv()
|
||||
pending_por_pdv = get_api_pendingorder_por_pdv()
|
||||
summary_rows = merge_orcamento_with_summary_data(
|
||||
summary_rows,
|
||||
saldo_orcamento_por_pdv,
|
||||
pending_por_pdv,
|
||||
)
|
||||
logger.info(
|
||||
"Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s",
|
||||
len(rows),
|
||||
len(summary_rows),
|
||||
len(saldo_orcamento_por_pdv),
|
||||
len(pending_por_pdv),
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': summary_rows,
|
||||
'count': len(summary_rows),
|
||||
'api_integrated': bool(api_data),
|
||||
'base_pdvs_integrated': bool(base_pdvs_data),
|
||||
'orcamento_integrated': bool(saldo_orcamento_por_pdv),
|
||||
'categorias': categorias,
|
||||
'classes': classes,
|
||||
'ciclos': ciclos,
|
||||
'tipos': tipos,
|
||||
'ciclo_aplicado': ciclo_filtro,
|
||||
'tipo_aplicado': tipo_filtro,
|
||||
'idpedido_aplicado': idpedido_filtro,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Retornar erro mas não quebrar a página
|
||||
logger.exception("Erro ao carregar dados da pivot")
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'data': []
|
||||
}, status=200)
|
||||
22
manage.py
Normal file
22
manage.py
Normal file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'aprovacao_pedidos.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
Django==5.2.5
|
||||
requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
gunicorn==21.2.0
|
||||
python-decouple==3.8
|
||||
211
test_api_integration.py
Normal file
211
test_api_integration.py
Normal file
@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script de teste da integração com API de Suprimentos.
|
||||
Execute com: python test_api_integration.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
# Setup Django
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'aprovacao_pedidos.settings')
|
||||
django.setup()
|
||||
|
||||
from home.views import get_api_suprimentos_data, merge_api_with_sql_data
|
||||
from django.conf import settings
|
||||
|
||||
def test_api_connection():
|
||||
"""Testa a conexão com a API de Suprimentos."""
|
||||
print("=" * 60)
|
||||
print("TESTE 1: Conexão com a API de Suprimentos")
|
||||
print("=" * 60)
|
||||
|
||||
# Verificar se token está configurado
|
||||
api_config = settings.API_SUPRIMENTOS_CONFIG
|
||||
token = api_config.get('TOKEN', '')
|
||||
|
||||
if not token:
|
||||
print("❌ ERRO: Token não configurado no .env")
|
||||
print(" Crie um arquivo .env com: API_SUPRIMENTOS_TOKEN=seu_token")
|
||||
return False
|
||||
|
||||
print(f"✓ Token encontrado: {token[:20]}...")
|
||||
print(f"✓ URL Detalhe: {api_config.get('DETALHE_URL')}")
|
||||
print(f"✓ URL Implantação: {api_config.get('IMPLANTACAO_URL')}")
|
||||
|
||||
# Tentar buscar dados
|
||||
print("\n📡 Conectando à API...")
|
||||
api_data = get_api_suprimentos_data()
|
||||
|
||||
if api_data is None:
|
||||
print("❌ Falha ao conectar com a API (verifique token e conexão de rede)")
|
||||
return False
|
||||
|
||||
if isinstance(api_data, list) and len(api_data) > 0:
|
||||
print(f"✓ Sucesso! {len(api_data)} registros obtidos da API")
|
||||
|
||||
# Mostrar exemplo de registro
|
||||
first_record = api_data[0]
|
||||
print("\nExemplo de registro da API:")
|
||||
for key in ['PDV', 'SKU', 'quantidade', 'Quantidade']:
|
||||
if key in first_record:
|
||||
print(f" - {key}: {first_record[key]}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("⚠️ API retornou lista vazia")
|
||||
return False
|
||||
|
||||
def test_data_merge():
|
||||
"""Testa o merge de dados SQL + API."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTE 2: Merge de dados (SQL + API)")
|
||||
print("=" * 60)
|
||||
|
||||
# Dados de exemplo do SQL
|
||||
sql_data = [
|
||||
{
|
||||
'PDV': '2001',
|
||||
'SKU': 'ABC123',
|
||||
'Descricao_PDV': 'Loja Centro',
|
||||
'Estoque_Total': 150,
|
||||
'sugestao_Compra_Total': 30
|
||||
},
|
||||
{
|
||||
'PDV': '2002',
|
||||
'SKU': 'XYZ789',
|
||||
'Descricao_PDV': 'Loja Norte',
|
||||
'Estoque_Total': 80,
|
||||
'sugestao_Compra_Total': 15
|
||||
}
|
||||
]
|
||||
|
||||
# Dados de exemplo da API
|
||||
api_data = [
|
||||
{
|
||||
'PDV': '2001',
|
||||
'SKU': 'ABC123',
|
||||
'quantidade': 45,
|
||||
'status': 'pendente'
|
||||
},
|
||||
{
|
||||
'PDV': '2003',
|
||||
'SKU': 'QWE456',
|
||||
'quantidade': 20,
|
||||
'status': 'pendente'
|
||||
}
|
||||
]
|
||||
|
||||
print(f"Registros SQL: {len(sql_data)}")
|
||||
print(f"Registros API: {len(api_data)}")
|
||||
|
||||
# Fazer merge
|
||||
merged = merge_api_with_sql_data(sql_data, api_data)
|
||||
|
||||
print(f"\nResultado após merge: {len(merged)} registros")
|
||||
|
||||
for row in merged:
|
||||
pdv = row.get('PDV')
|
||||
sku = row.get('SKU')
|
||||
sugestao = row.get('sugestao_analista')
|
||||
presente = row.get('dados_api_presentes')
|
||||
|
||||
status = "✓ API" if presente else "- SQL"
|
||||
print(f" {status} | PDV: {pdv}, SKU: {sku}, Sugestão: {sugestao}")
|
||||
|
||||
# Verificar se o merge funcionou
|
||||
encontrou_match = any(row.get('dados_api_presentes') for row in merged)
|
||||
if encontrou_match:
|
||||
print("\n✓ Merge funcionando corretamente!")
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Nenhum match encontrado (PDV/SKU diferentes)")
|
||||
return True
|
||||
|
||||
def test_full_integration():
|
||||
"""Testa a integração completa."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTE 3: Integração Completa")
|
||||
print("=" * 60)
|
||||
|
||||
# Buscar dados da API
|
||||
api_data = get_api_suprimentos_data()
|
||||
|
||||
if not api_data:
|
||||
print("⚠️ API não disponível para teste completo")
|
||||
return False
|
||||
|
||||
print(f"✓ Dados da API carregados: {len(api_data)} registros")
|
||||
|
||||
# Simular dados do SQL
|
||||
sql_sample = [
|
||||
{
|
||||
'PDV': str(api_data[0].get('PDV', '')) if api_data else '0',
|
||||
'SKU': str(api_data[0].get('SKU', '')) if api_data else '0',
|
||||
'Estoque_Total': 100,
|
||||
}
|
||||
]
|
||||
|
||||
merged = merge_api_with_sql_data(sql_sample, api_data)
|
||||
|
||||
if merged[0].get('dados_api_presentes'):
|
||||
print("✓ Merge bem-sucedido!")
|
||||
print(f" - sugestao_analista: {merged[0].get('sugestao_analista')}")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ Sem matches (pode ser esperado com dados de exemplo)")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""Executa todos os testes."""
|
||||
print("\n🧪 TESTES DE INTEGRAÇÃO COM API DE SUPRIMENTOS\n")
|
||||
|
||||
results = []
|
||||
|
||||
# Teste 1
|
||||
try:
|
||||
results.append(("Conexão com API", test_api_connection()))
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erro no Teste 1: {e}")
|
||||
results.append(("Conexão com API", False))
|
||||
|
||||
# Teste 2
|
||||
try:
|
||||
results.append(("Merge de dados", test_data_merge()))
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erro no Teste 2: {e}")
|
||||
results.append(("Merge de dados", False))
|
||||
|
||||
# Teste 3
|
||||
try:
|
||||
results.append(("Integração completa", test_full_integration()))
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erro no Teste 3: {e}")
|
||||
results.append(("Integração completa", False))
|
||||
|
||||
# Resumo
|
||||
print("\n" + "=" * 60)
|
||||
print("RESUMO DOS TESTES")
|
||||
print("=" * 60)
|
||||
|
||||
for test_name, passed in results:
|
||||
status = "✓ PASSOU" if passed else "❌ FALHOU"
|
||||
print(f"{status}: {test_name}")
|
||||
|
||||
total_passed = sum(1 for _, passed in results if passed)
|
||||
total = len(results)
|
||||
|
||||
print(f"\nTotal: {total_passed}/{total} testes passaram")
|
||||
|
||||
if total_passed == total:
|
||||
print("\n🎉 Todos os testes passaram! Integração pronta para usar.")
|
||||
return 0
|
||||
else:
|
||||
print("\n⚠️ Alguns testes falharam. Verifique a configuração.")
|
||||
return 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user