1370 lines
52 KiB
Python
1370 lines
52 KiB
Python
from django.shortcuts import render
|
|
from django.http import JsonResponse
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.utils import timezone
|
|
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 controle_saldo_page(request):
|
|
"""Página simulador de transferência de saldo entre PDVs."""
|
|
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/controle_saldo.html", context)
|
|
|
|
|
|
def get_controle_saldo_data(request):
|
|
"""Retorna saldo disponível por PDV para simulação de transferência."""
|
|
try:
|
|
saldo_orcamento_por_pdv_marca = get_api_orcamento_saldo_por_pdv_marca()
|
|
pending_por_pdv_marca = get_api_pendingorder_por_pdv_marca()
|
|
pending_ignorado_por_pdv_marca = get_api_pendingorder_ignorados_por_pdv_marca()
|
|
cobertura_por_pdv_marca = get_home_cobertura_por_pdv_marca()
|
|
base_pdvs_data = get_api_base_pdvs_data() or []
|
|
|
|
descricao_por_pdv: Dict[str, str] = {}
|
|
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
|
|
descricao = str(
|
|
row.get(
|
|
'descricao_pdv',
|
|
row.get(
|
|
'DESCRICAO_PDV',
|
|
row.get('descricao', row.get('DESCRICAO', row.get('nome', row.get('NOME', ''))))
|
|
)
|
|
)
|
|
or ''
|
|
).strip()
|
|
if descricao:
|
|
descricao_por_pdv[pdv] = descricao
|
|
|
|
chaves_unicas = (
|
|
set(saldo_orcamento_por_pdv_marca.keys())
|
|
| set(pending_por_pdv_marca.keys())
|
|
| set(pending_ignorado_por_pdv_marca.keys())
|
|
)
|
|
pdvs = []
|
|
for key in chaves_unicas:
|
|
pdv, marca = key.split('|', 1)
|
|
saldo = float(saldo_orcamento_por_pdv_marca.get(key, 0.0))
|
|
pending = float(pending_por_pdv_marca.get(key, 0.0))
|
|
pending_ignorado = float(pending_ignorado_por_pdv_marca.get(key, 0.0))
|
|
orcamento_disponivel = saldo - pending + pending_ignorado
|
|
pdvs.append({
|
|
'key': key,
|
|
'pdv': pdv,
|
|
'marca': marca,
|
|
'descricao_pdv': descricao_por_pdv.get(pdv, ''),
|
|
'orcamento_disponivel': orcamento_disponivel,
|
|
'cobertura_dias': float(cobertura_por_pdv_marca.get(key, 0.0)),
|
|
'orcamento_bruto': saldo,
|
|
'pendente_api': pending,
|
|
'pendente_ignorado_api': pending_ignorado,
|
|
})
|
|
|
|
pdvs.sort(key=lambda item: (
|
|
(item.get('descricao_pdv') or '').lower(),
|
|
(item.get('pdv') or ''),
|
|
(item.get('marca') or ''),
|
|
))
|
|
return JsonResponse({
|
|
'status': 'success',
|
|
'data': pdvs,
|
|
'count': len(pdvs),
|
|
})
|
|
except Exception as e:
|
|
logger.exception("Erro ao carregar dados de controle de saldo")
|
|
return JsonResponse({
|
|
'status': 'error',
|
|
'message': str(e),
|
|
'data': []
|
|
}, status=200)
|
|
|
|
|
|
def get_home_cobertura_por_pdv() -> Dict[str, float]:
|
|
"""Calcula cobertura (dias) agregada por PDV usando a mesma base SQL do /home."""
|
|
try:
|
|
cache_key = "home_cobertura_pdv"
|
|
cached_data = cache.get(cache_key)
|
|
if cached_data is not None:
|
|
return cached_data
|
|
|
|
query = """
|
|
WITH draft as (
|
|
SELECT
|
|
dh.businessunit AS MARCA,
|
|
dh.loja_id AS PDV,
|
|
dh.code AS SKU,
|
|
dh.stock_actual AS ESTOQUE,
|
|
dh.pendingorder AS PENDENTE,
|
|
dh.stock_intransit AS TRANSITO
|
|
FROM draft_historico dh with(nolock)
|
|
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
|
|
)
|
|
SELECT
|
|
emh.PDV,
|
|
emh.SKU,
|
|
emh.[DDV PREVISTO] AS DDV_PREVISTO,
|
|
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
|
|
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
|
|
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
|
|
FROM estoque_mar_historico emh with(nolock)
|
|
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')
|
|
"""
|
|
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()]
|
|
|
|
agg: Dict[str, Dict[str, float]] = {}
|
|
for row in rows:
|
|
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
|
if not pdv:
|
|
continue
|
|
if pdv not in agg:
|
|
agg[pdv] = {'estoque': 0.0, 'ddv': 0.0}
|
|
|
|
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
|
|
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
|
|
agg[pdv]['estoque'] += estoque
|
|
agg[pdv]['ddv'] += ddv
|
|
|
|
cobertura_por_pdv: Dict[str, float] = {}
|
|
for pdv, vals in agg.items():
|
|
ddv = vals['ddv']
|
|
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
|
|
cobertura_por_pdv[pdv] = round(cobertura, 0)
|
|
|
|
cache.set(cache_key, cobertura_por_pdv, timeout=300)
|
|
return cobertura_por_pdv
|
|
except Exception as e:
|
|
logger.error("Erro ao processar cobertura SQL do /home (PDV): %s", e)
|
|
return {}
|
|
|
|
|
|
def get_home_cobertura_por_pdv_marca() -> Dict[str, float]:
|
|
"""Calcula cobertura (dias) por PDV+MARCA usando a mesma base SQL do /home."""
|
|
try:
|
|
cache_key = "home_cobertura_pdv_marca"
|
|
cached_data = cache.get(cache_key)
|
|
if cached_data is not None:
|
|
return cached_data
|
|
|
|
query = """
|
|
WITH draft as (
|
|
SELECT
|
|
dh.businessunit AS MARCA,
|
|
dh.loja_id AS PDV,
|
|
dh.code AS SKU,
|
|
dh.stock_actual AS ESTOQUE,
|
|
dh.pendingorder AS PENDENTE,
|
|
dh.stock_intransit AS TRANSITO
|
|
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,
|
|
emh.PDV,
|
|
emh.SKU,
|
|
emh.[DDV PREVISTO] AS DDV_PREVISTO,
|
|
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
|
|
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
|
|
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
|
|
FROM estoque_mar_historico emh with(nolock)
|
|
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')
|
|
"""
|
|
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()]
|
|
|
|
agg: Dict[str, Dict[str, float]] = {}
|
|
for row in rows:
|
|
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
|
marca = _normalize_marca(row.get('MARCA', row.get('marca', '')))
|
|
if not pdv or not marca:
|
|
continue
|
|
key = f"{pdv}|{marca}"
|
|
if key not in agg:
|
|
agg[key] = {'estoque': 0.0, 'transito': 0.0, 'pendente': 0.0, 'ddv': 0.0}
|
|
|
|
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
|
|
transito = _parse_float(row.get('TRANSITO', row.get('transito', 0)))
|
|
pendente = _parse_float(row.get('PENDENTE', row.get('pendente', 0)))
|
|
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
|
|
|
|
agg[key]['estoque'] += estoque
|
|
agg[key]['transito'] += transito
|
|
agg[key]['pendente'] += pendente
|
|
agg[key]['ddv'] += ddv
|
|
|
|
cobertura_por_pdv_marca: Dict[str, float] = {}
|
|
for key, vals in agg.items():
|
|
ddv = vals['ddv']
|
|
# No controle de saldo exibimos cobertura atual (estoque / ddv),
|
|
# alinhada com a validação manual solicitada.
|
|
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
|
|
cobertura_por_pdv_marca[key] = round(cobertura, 0)
|
|
|
|
cache.set(cache_key, cobertura_por_pdv_marca, timeout=300)
|
|
return cobertura_por_pdv_marca
|
|
except Exception as e:
|
|
logger.error("Erro ao processar cobertura SQL do /home (PDV+MARCA): %s", e)
|
|
return {}
|
|
|
|
|
|
def get_api_orcamento_saldo_por_pdv_marca(mes: Optional[int] = None) -> Dict[str, float]:
|
|
"""Busca saldo_pdv por PDV+MARCA 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_marca: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 (PDV+MARCA): %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_marca: 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', '')))))
|
|
marca = _normalize_marca(row.get('brand', row.get('BRAND', row.get('marca', row.get('MARCA', '')))))
|
|
if not pdv or not marca:
|
|
continue
|
|
saldo = _parse_float(row.get('saldo_pdv', row.get('SALDO_PDV', 0)))
|
|
key = f"{pdv}|{marca}"
|
|
saldo_por_pdv_marca[key] = saldo_por_pdv_marca.get(key, 0.0) + saldo
|
|
|
|
cache.set(cache_key, saldo_por_pdv_marca, timeout=300)
|
|
return saldo_por_pdv_marca
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error("Erro de conexão com API orçamento (PDV+MARCA): %s", e)
|
|
return {}
|
|
except Exception as e:
|
|
logger.error("Erro ao processar API orçamento (PDV+MARCA): %s", e)
|
|
return {}
|
|
|
|
|
|
def get_api_pendingorder_por_pdv_marca() -> Dict[str, float]:
|
|
"""Busca pendingorder por PDV+MARCA na API e agrega por loja/marca."""
|
|
try:
|
|
cache_key = "api_pendingorder_pdv_marca"
|
|
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 (PDV+MARCA): %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_marca: Dict[str, float] = {}
|
|
for row in rows:
|
|
pdv = _normalize_pdv(row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', '')))))
|
|
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
|
|
if not pdv or not marca:
|
|
continue
|
|
pending = _parse_float(
|
|
row.get(
|
|
'total_pricesellin',
|
|
row.get('TOTAL_PRICESELLIN', row.get('pendingorder', row.get('PENDINGORDER', row.get('pendente', 0))))
|
|
)
|
|
)
|
|
key = f"{pdv}|{marca}"
|
|
pending_por_pdv_marca[key] = pending_por_pdv_marca.get(key, 0.0) + pending
|
|
|
|
cache.set(cache_key, pending_por_pdv_marca, timeout=300)
|
|
return pending_por_pdv_marca
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error("Erro de conexão com API pendingorder (PDV+MARCA): %s", e)
|
|
return {}
|
|
except Exception as e:
|
|
logger.error("Erro ao processar API pendingorder (PDV+MARCA): %s", e)
|
|
return {}
|
|
|
|
|
|
def get_api_pendingorder_ignorados_por_pdv_marca() -> Dict[str, float]:
|
|
"""Busca pendingorder ignorado por PDV+MARCA na API e agrega por loja/marca."""
|
|
try:
|
|
cache_key = "api_pendingorder_ignorados_pdv_marca"
|
|
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_ignorados?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 ignorados (PDV+MARCA): %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_ignorado_por_pdv_marca: Dict[str, float] = {}
|
|
for row in rows:
|
|
pdv = _normalize_pdv(
|
|
row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', ''))))
|
|
)
|
|
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
|
|
if not pdv or not marca:
|
|
continue
|
|
pending_ignorado = _parse_float(
|
|
row.get(
|
|
'total_ignorado',
|
|
row.get(
|
|
'TOTAL_IGNORADO',
|
|
row.get(
|
|
'total_pricesellin',
|
|
row.get(
|
|
'TOTAL_PRICESELLIN',
|
|
row.get(
|
|
'pendingorder',
|
|
row.get('PENDINGORDER', row.get('pendente', 0))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
key = f"{pdv}|{marca}"
|
|
pending_ignorado_por_pdv_marca[key] = pending_ignorado_por_pdv_marca.get(key, 0.0) + pending_ignorado
|
|
|
|
cache.set(cache_key, pending_ignorado_por_pdv_marca, timeout=300)
|
|
return pending_ignorado_por_pdv_marca
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error("Erro de conexão com API pendingorder ignorados (PDV+MARCA): %s", e)
|
|
return {}
|
|
except Exception as e:
|
|
logger.error("Erro ao processar API pendingorder ignorados (PDV+MARCA): %s", e)
|
|
return {}
|
|
|
|
|
|
def get_table_data(request):
|
|
"""API endpoint que retorna os dados da query em formato JSON."""
|
|
try:
|
|
query = """
|
|
WITH draft as (
|
|
SELECT
|
|
dh.data,
|
|
CASE WHEN dh.description like '%OUI%' then 'OUI' else businessunit end 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 get_api_pendingorder_ignorados_por_pdv() -> Dict[str, float]:
|
|
"""Busca pendingorder ignorado por PDV na API e agrega por loja."""
|
|
try:
|
|
cache_key = "api_pendingorder_ignorados_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_ignorados?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 ignorados: %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_ignorado_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
|
|
pending_ignorado = _parse_float(
|
|
row.get(
|
|
'total_ignorado',
|
|
row.get(
|
|
'TOTAL_IGNORADO',
|
|
row.get(
|
|
'total_pricesellin',
|
|
row.get(
|
|
'TOTAL_PRICESELLIN',
|
|
row.get(
|
|
'pendingorder',
|
|
row.get('PENDINGORDER', row.get('pendente', 0))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
pending_ignorado_por_pdv[pdv] = pending_ignorado_por_pdv.get(pdv, 0.0) + pending_ignorado
|
|
|
|
cache.set(cache_key, pending_ignorado_por_pdv, timeout=300)
|
|
return pending_ignorado_por_pdv
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error("Erro de conexão com API pendingorder ignorados: %s", e)
|
|
return {}
|
|
except Exception as e:
|
|
logger.error("Erro ao processar API pendingorder ignorados: %s", e)
|
|
return {}
|
|
|
|
|
|
def get_api_aprovado_hoje_total() -> float:
|
|
"""Soma o valor aprovado no dia atual (dtimplantacao == hoje) na API de detalhepedido."""
|
|
try:
|
|
hoje = timezone.localdate()
|
|
cache_key = f"api_aprovado_hoje_total:{hoje.isoformat()}"
|
|
cached_data = cache.get(cache_key)
|
|
if cached_data is not None:
|
|
return float(cached_data)
|
|
|
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
|
token = api_config.get('TOKEN')
|
|
url = 'https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=aprovado'
|
|
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 aprovados de hoje: %s", response.status_code)
|
|
return 0.0
|
|
|
|
payload = response.json()
|
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
|
if not isinstance(rows, list):
|
|
return 0.0
|
|
|
|
def _is_row_hoje(dt_value) -> bool:
|
|
if dt_value in (None, ''):
|
|
return False
|
|
text = str(dt_value).strip()
|
|
if not text:
|
|
return False
|
|
iso_candidate = text.replace('Z', '+00:00')
|
|
try:
|
|
dt = datetime.fromisoformat(iso_candidate)
|
|
return dt.date() == hoje
|
|
except Exception:
|
|
pass
|
|
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%d %H:%M:%S", "%d/%m/%Y %H:%M:%S"):
|
|
try:
|
|
return datetime.strptime(text, fmt).date() == hoje
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
total_aprovado_hoje = 0.0
|
|
for row in rows:
|
|
if not _is_row_hoje(row.get('dtimplantacao', row.get('DTIMPLANTACAO'))):
|
|
continue
|
|
valor = _parse_float(
|
|
row.get(
|
|
'total_pricesellin',
|
|
row.get(
|
|
'TOTAL_PRICESELLIN',
|
|
row.get(
|
|
'total_ignorado',
|
|
row.get(
|
|
'TOTAL_IGNORADO',
|
|
row.get('valor_total', row.get('VALOR_TOTAL', 0))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
total_aprovado_hoje += valor
|
|
|
|
cache.set(cache_key, total_aprovado_hoje, timeout=300)
|
|
return total_aprovado_hoje
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error("Erro de conexão com API aprovados de hoje: %s", e)
|
|
return 0.0
|
|
except Exception as e:
|
|
logger.error("Erro ao processar API aprovados de hoje: %s", e)
|
|
return 0.0
|
|
|
|
|
|
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_marca(value) -> str:
|
|
"""Normaliza marca para comparação entre fontes diferentes."""
|
|
text = str(value or '').strip()
|
|
if not text:
|
|
return ''
|
|
return text.upper()
|
|
|
|
|
|
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,
|
|
pending_ignorado_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 {}
|
|
pending_ignorado_por_pdv = pending_ignorado_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))
|
|
pending_ignorado = float(pending_ignorado_por_pdv.get(pdv, 0.0))
|
|
row['Orcamento_Bruto'] = saldo
|
|
row['Pendente_API'] = pending
|
|
row['Pendente_Ignorado_API'] = pending_ignorado
|
|
row['Orcamento_Disponivel'] = saldo - pending + pending_ignorado
|
|
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()
|
|
pending_ignorado_por_pdv = get_api_pendingorder_ignorados_por_pdv()
|
|
summary_rows = merge_orcamento_with_summary_data(
|
|
summary_rows,
|
|
saldo_orcamento_por_pdv,
|
|
pending_por_pdv,
|
|
pending_ignorado_por_pdv,
|
|
)
|
|
aprovado_hoje_total = get_api_aprovado_hoje_total()
|
|
logger.info(
|
|
"Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s pdvs_pending_ignorados=%s aprovado_hoje_total=%s",
|
|
len(rows),
|
|
len(summary_rows),
|
|
len(saldo_orcamento_por_pdv),
|
|
len(pending_por_pdv),
|
|
len(pending_ignorado_por_pdv),
|
|
round(aprovado_hoje_total, 2),
|
|
)
|
|
|
|
return JsonResponse({
|
|
'status': 'success',
|
|
'data': summary_rows,
|
|
'count': len(summary_rows),
|
|
'aprovado_hoje_total': aprovado_hoje_total,
|
|
'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)
|