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)