Server : Apache System : Linux dedi-14684855.grupobig.com 5.14.0-611.49.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Apr 21 16:39:08 EDT 2026 x86_64 User : grupo692 ( 1004) PHP Version : 8.2.31 Disable Function : NONE Directory : /opt/dash_backend_new/app/ |
# app/dashboard.py
from datetime import date, timedelta
import calendar
from typing import Optional
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user, UserTokenData
from .db import get_db_conn
from .tenant_oracle_db import get_tenant_context, TenantContext
from .sandbox_data import is_sandbox_empresa, dashboard_resumo_fake
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
class DashboardResumoResponse(BaseModel):
total_vendas: float
qtd_vendas: int
ticket_medio: float
percentual_meta: float
meta_mes: float
projecao_total: float
projecao_total_pct: float
projecao_start: date
projecao_end: date
projecao_valor_liq: float
projecao_denom_meta_peso: float
total_vendas_yoy: float
qtd_vendas_yoy: int
ticket_medio_yoy: float
crescimento_yoy_pct: float
qtd_vendas_yoy_pct: float
ticket_medio_yoy_pct: float
start_date: date
end_date: date
start_date_yoy: date
end_date_yoy: date
def _shift_year(d: date, years: int) -> date:
try:
return d.replace(year=d.year + years)
except ValueError:
# 29/02 em ano não bissexto
return d.replace(month=2, day=28, year=d.year + years)
def _month_start(d: date) -> date:
return date(d.year, d.month, 1)
def _month_last_day(d: date) -> date:
last_day = calendar.monthrange(d.year, d.month)[1]
return date(d.year, d.month, last_day)
def _bind_in_params(prefix: str, values: list[int]) -> tuple[str, dict]:
"""
Oracle não aceita bind de lista direto no IN.
Então criamos :u0,:u1,:u2... e devolvemos também o dict dos params.
"""
params: dict = {}
keys: list[str] = []
for i, v in enumerate(values):
k = f"{prefix}{i}"
keys.append(f":{k}")
params[k] = int(v)
return ", ".join(keys), params
def _effective_end_for_yoy(start_date: date, end_date: date) -> date:
"""
Ajusta o fim do período APENAS para o comparativo YoY.
Caso o range selecionado pegue dias futuros do mês atual
(ex.: preset "Mês" do app envia 01..último dia do mês),
o YoY deve comparar somente até hoje.
Exemplo:
hoje = 2026-03-19
start_date = 2026-03-01
end_date = 2026-03-31
resultado:
effective_end = 2026-03-19
yoy_end = 2025-03-19
Isso evita que o YoY use o mês completo do ano anterior.
"""
today = date.today()
if start_date <= today and end_date > today:
return today
return end_date
# -----------------------------------------------------------------------------
# META MODE (compatibilidade com app publicado)
# -----------------------------------------------------------------------------
def _is_month_to_date_current_month(start_date: date, end_date: date) -> bool:
"""
Heurística antiga do app:
start=1º do mês atual e end=hoje.
"""
today = date.today()
return (
start_date.year == today.year
and start_date.month == today.month
and start_date.day == 1
and end_date == today
)
def _resolve_meta_mode(meta_mode: Optional[str], start_date: date, end_date: date) -> str:
"""
Retorna "full_month" ou "period".
- Se meta_mode vier explícito, respeita.
- Se NÃO vier, preserva comportamento do app publicado:
* quando o range for (1º do mês atual -> hoje), usa meta do mês inteiro.
- Caso contrário, usa meta do período (correto para Dia/Semana/Quinzena/Livre).
"""
if meta_mode:
mm = meta_mode.strip().lower()
if mm in ("full_month", "month", "mensal", "mes", "mês"):
return "full_month"
if mm in ("period", "range", "periodo", "período", "periodo"):
return "period"
return "full_month" if _is_month_to_date_current_month(start_date, end_date) else "period"
def _calc_meta_full_month(conn, unidades: list[int], month_start: date) -> float:
if not unidades:
return 0.0
in_clause, in_params = _bind_in_params("u", unidades)
q = f"""
SELECT NVL(SUM(MTA_META), 0) AS META_MES
FROM DBAUSER.BIG_METAS
WHERE TRUNC(MTA_MES_ANO, 'MM') = TRUNC(:mes_inicio, 'MM')
AND MTA_UNIDADE_FK_PK IN ({in_clause})
"""
params = {"mes_inicio": month_start, **in_params}
try:
df = pd.read_sql(q, con=conn, params=params)
if df.empty:
return 0.0
return float(df.iloc[0]["META_MES"] or 0.0)
except Exception:
return 0.0
def _calc_meta_period_by_peso(conn, unidades: list[int], start_date: date, end_date: date) -> float:
"""
Meta do período via pesos diários:
Σ (MTA_PESO(dia) * META_MENSAL(unidade, mês do dia))
Isso atende:
- Semana (segunda..domingo)
- Quinzena (01..15 ou 16..último)
- Livre (range informado)
- Dia (apenas o dia)
"""
if not unidades:
return 0.0
in_clause, in_params = _bind_in_params("u", unidades)
q = f"""
SELECT NVL(SUM(p.MTA_PESO * m.MTA_META), 0) AS META_PERIODO
FROM DBAUSER.BIG_METAS_PESO p
JOIN DBAUSER.BIG_METAS m
ON m.MTA_UNIDADE_FK_PK = p.MTA_UNIDADE_FK_PK
AND TRUNC(m.MTA_MES_ANO, 'MM') = TRUNC(p.MTA_DATA, 'MM')
WHERE p.MTA_DATA >= :start_date
AND p.MTA_DATA < (:end_date + 1)
AND p.MTA_UNIDADE_FK_PK IN ({in_clause})
"""
params = {"start_date": start_date, "end_date": end_date, **in_params}
try:
df = pd.read_sql(q, con=conn, params=params)
if df.empty:
return 0.0
return float(df.iloc[0]["META_PERIODO"] or 0.0)
except Exception:
return 0.0
def _calc_meta(conn, unidades: list[int], start_date: date, end_date: date, meta_mode: Optional[str]) -> float:
mode = _resolve_meta_mode(meta_mode, start_date, end_date)
if mode == "full_month":
return _calc_meta_full_month(conn, unidades, _month_start(start_date))
return _calc_meta_period_by_peso(conn, unidades, start_date, end_date)
# -----------------------------------------------------------------------------
# RESUMO (vendas / descontos / projeção)
# -----------------------------------------------------------------------------
def _fetch_resumo(
conn,
unidades: list[int],
start_date: date,
end_date: date,
proj_start: date,
proj_end: date,
meta_mode: Optional[str],
) -> dict:
if not unidades:
return {
"qtd_vendas": 0,
"total_vendas": 0.0,
"ticket_medio": 0.0,
"percentual_meta": 0.0,
"meta_mes": 0.0,
"projecao_valor_liq": 0.0,
"projecao_denom_meta_peso": 0.0,
"projecao_total": 0.0,
"projecao_total_pct": 0.0,
}
in_clause, in_params = _bind_in_params("u", unidades)
query = f"""
WITH
VENDAS AS (
SELECT COUNT(*) AS QTD, SUM(TPED_VALOR_TOTAL_PEDIDO) AS TOTAL
FROM TPED_PEDIDO_VENDA
WHERE TPED_UNIDADE_FK_PK IN ({in_clause})
AND TPED_DATA_EMISSAO >= :start_date
AND TPED_DATA_EMISSAO < (:end_date + 1)
AND TPED_STATUS_PEDIDO = 'MA'
AND TPED_NATUREZA_MOVIMENTACAO = 'VM'
UNION ALL
SELECT COUNT(*) AS QTD, SUM(TPED_VALOR_TOTAL_PEDIDO) AS TOTAL
FROM TPED_HISTORICO_VENDA
WHERE TPED_UNIDADE_FK_PK IN ({in_clause})
AND TPED_DATA_EMISSAO >= :start_date
AND TPED_DATA_EMISSAO < (:end_date + 1)
AND TPED_STATUS_PEDIDO = 'MA'
AND TPED_NATUREZA_MOVIMENTACAO = 'VM'
),
AGG AS (
SELECT NVL(SUM(QTD), 0) AS QTD_VENDAS,
NVL(SUM(TOTAL), 0) AS TOTAL_BRUTO
FROM VENDAS
),
DESCONTO AS (
SELECT NVL(SUM(TMOV_VALOR_TOTAL), 0) AS TOTAL_DC
FROM TMOV_EXTRA
WHERE TMOV_NATUREZA_MOVIMENTACAO = 'DC'
AND TMOV_ESTADO_MOVIMENTACAO = 'CL'
AND TMOV_APENAS_IMPRESSAO = 'N'
AND TMOV_UNIDADE_FK IN ({in_clause})
AND TMOV_DATA_DOCUMENTO >= :start_date
AND TMOV_DATA_DOCUMENTO < (:end_date + 1)
),
-- =========================================
-- PROJEÇÃO (mês do período selecionado até ontem)
-- =========================================
VENDAS_PROJ AS (
SELECT
SUM(TPED_VALOR_TOTAL_PEDIDO) AS TOTAL
FROM TPED_PEDIDO_VENDA
WHERE TPED_UNIDADE_FK_PK IN ({in_clause})
AND TPED_DATA_EMISSAO >= :proj_start
AND TPED_DATA_EMISSAO < (:proj_end + 1)
AND TPED_STATUS_PEDIDO = 'MA'
AND TPED_NATUREZA_MOVIMENTACAO = 'VM'
UNION ALL
SELECT
SUM(TPED_VALOR_TOTAL_PEDIDO) AS TOTAL
FROM TPED_HISTORICO_VENDA
WHERE TPED_UNIDADE_FK_PK IN ({in_clause})
AND TPED_DATA_EMISSAO >= :proj_start
AND TPED_DATA_EMISSAO < (:proj_end + 1)
AND TPED_STATUS_PEDIDO = 'MA'
AND TPED_NATUREZA_MOVIMENTACAO = 'VM'
),
AGG_PROJ AS (
SELECT NVL(SUM(TOTAL), 0) AS TOTAL_BRUTO FROM VENDAS_PROJ
),
DESCONTO_PROJ AS (
SELECT NVL(SUM(TMOV_VALOR_TOTAL), 0) AS TOTAL_DC
FROM TMOV_EXTRA
WHERE TMOV_NATUREZA_MOVIMENTACAO = 'DC'
AND TMOV_ESTADO_MOVIMENTACAO = 'CL'
AND TMOV_APENAS_IMPRESSAO = 'N'
AND TMOV_UNIDADE_FK IN ({in_clause})
AND TMOV_DATA_DOCUMENTO >= :proj_start
AND TMOV_DATA_DOCUMENTO < (:proj_end + 1)
),
PESO_UN AS (
SELECT
MTA_UNIDADE_FK_PK AS UNIDADE,
SUM(MTA_PESO) AS PESO
FROM DBAUSER.BIG_METAS_PESO
WHERE MTA_DATA >= :proj_start
AND MTA_DATA < (:proj_end + 1)
AND MTA_UNIDADE_FK_PK IN ({in_clause})
GROUP BY MTA_UNIDADE_FK_PK
),
META_UN AS (
SELECT
MTA_UNIDADE_FK_PK AS UNIDADE,
MTA_META AS META
FROM DBAUSER.BIG_METAS
WHERE TRUNC(MTA_MES_ANO, 'MM') = TRUNC(:proj_start, 'MM')
AND MTA_UNIDADE_FK_PK IN ({in_clause})
),
DENOM AS (
SELECT NVL(SUM(NVL(M.META,0) * NVL(P.PESO,0)), 0) AS META_PESO_TOTAL
FROM META_UN M
LEFT JOIN PESO_UN P ON P.UNIDADE = M.UNIDADE
)
SELECT
A.QTD_VENDAS AS QTD_VENDAS,
ROUND((A.TOTAL_BRUTO - D.TOTAL_DC), 2) AS TOTAL_LIQ,
CASE
WHEN A.QTD_VENDAS > 0
THEN ROUND(ROUND((A.TOTAL_BRUTO - D.TOTAL_DC), 2) / A.QTD_VENDAS, 2)
ELSE 0
END AS TICKET_MEDIO,
ROUND((AP.TOTAL_BRUTO - DP.TOTAL_DC), 2) AS PROJ_VALOR_LIQ,
DN.META_PESO_TOTAL AS PROJ_META_PESO_TOTAL,
CASE
WHEN DN.META_PESO_TOTAL > 0
THEN ROUND(ROUND((AP.TOTAL_BRUTO - DP.TOTAL_DC), 2) / DN.META_PESO_TOTAL, 4)
ELSE 0
END AS PROJ_TOTAL,
CASE
WHEN DN.META_PESO_TOTAL > 0
THEN ROUND((ROUND(ROUND((AP.TOTAL_BRUTO - DP.TOTAL_DC), 2) / DN.META_PESO_TOTAL, 4)) * 100, 2)
ELSE 0
END AS PROJ_PCT
FROM AGG A
CROSS JOIN DESCONTO D
CROSS JOIN AGG_PROJ AP
CROSS JOIN DESCONTO_PROJ DP
CROSS JOIN DENOM DN
"""
try:
params = {
"start_date": start_date,
"end_date": end_date,
"proj_start": proj_start,
"proj_end": proj_end,
**in_params,
}
df = pd.read_sql(query, con=conn, params=params)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Erro ao executar resumo do dashboard: {exc}")
meta_val = _calc_meta(conn, unidades, start_date, end_date, meta_mode)
if df.empty:
return {
"qtd_vendas": 0,
"total_vendas": 0.0,
"ticket_medio": 0.0,
"percentual_meta": 0.0,
"meta_mes": float(round(meta_val, 2)),
"projecao_valor_liq": 0.0,
"projecao_denom_meta_peso": 0.0,
"projecao_total": 0.0,
"projecao_total_pct": 0.0,
}
row = df.iloc[0].to_dict()
total_vendas = float(row.get("TOTAL_LIQ", 0) or 0)
pct_meta = 0.0
if meta_val > 0:
pct_meta = round((total_vendas / meta_val) * 100, 2)
return {
"qtd_vendas": int(row.get("QTD_VENDAS", 0) or 0),
"total_vendas": total_vendas,
"ticket_medio": float(row.get("TICKET_MEDIO", 0) or 0),
"percentual_meta": float(pct_meta),
"meta_mes": float(round(meta_val, 2)),
"projecao_valor_liq": float(row.get("PROJ_VALOR_LIQ", 0) or 0),
"projecao_denom_meta_peso": float(row.get("PROJ_META_PESO_TOTAL", 0) or 0),
"projecao_total": float(row.get("PROJ_TOTAL", 0) or 0),
"projecao_total_pct": float(row.get("PROJ_PCT", 0) or 0),
}
@router.get("/resumo", response_model=DashboardResumoResponse)
def resumo(
start_date: date,
end_date: date,
meta_mode: Optional[str] = None,
current_user: UserTokenData = Depends(get_current_user),
ctx: TenantContext = Depends(get_tenant_context),
conn=Depends(get_db_conn),
):
# -------------------------------------------------------------------------
# SANDBOX: retorna dados fictícios (mesma "cara" do PROD) e NÃO usa Oracle
# -------------------------------------------------------------------------
if is_sandbox_empresa(ctx.empresa) or getattr(ctx, "sandbox", False):
return dashboard_resumo_fake(start_date, end_date, ctx.unidades)
# Período YoY = mesmo intervalo, mas com fim efetivo comparável
effective_end = _effective_end_for_yoy(start_date, end_date)
start_yoy = _shift_year(start_date, -1)
end_yoy = _shift_year(effective_end, -1)
# Projeção do mês do período selecionado até ontem
proj_start = _month_start(start_date)
ontem = date.today() - timedelta(days=1)
proj_end = min(ontem, _month_last_day(start_date))
atual = _fetch_resumo(conn, ctx.unidades, start_date, end_date, proj_start, proj_end, meta_mode)
yoy = _fetch_resumo(
conn,
ctx.unidades,
start_yoy,
end_yoy,
start_yoy,
end_yoy,
meta_mode,
)
total_atual = atual["total_vendas"]
total_yoy = yoy["total_vendas"]
crescimento_pct = 0.0
if total_yoy > 0:
crescimento_pct = round(((total_atual - total_yoy) / total_yoy) * 100, 2)
qtd_atual = atual["qtd_vendas"]
qtd_yoy = yoy["qtd_vendas"]
qtd_yoy_pct = 0.0
if qtd_yoy > 0:
qtd_yoy_pct = round(((qtd_atual - qtd_yoy) / qtd_yoy) * 100, 2)
tkt_atual = atual["ticket_medio"]
tkt_yoy = yoy["ticket_medio"]
tkt_yoy_pct = 0.0
if tkt_yoy > 0:
tkt_yoy_pct = round(((tkt_atual - tkt_yoy) / tkt_yoy) * 100, 2)
return DashboardResumoResponse(
total_vendas=total_atual,
qtd_vendas=qtd_atual,
ticket_medio=tkt_atual,
percentual_meta=atual["percentual_meta"],
meta_mes=atual["meta_mes"],
projecao_total=atual["projecao_total"],
projecao_total_pct=atual["projecao_total_pct"],
projecao_start=proj_start,
projecao_end=proj_end,
projecao_valor_liq=atual["projecao_valor_liq"],
projecao_denom_meta_peso=atual["projecao_denom_meta_peso"],
total_vendas_yoy=total_yoy,
qtd_vendas_yoy=qtd_yoy,
ticket_medio_yoy=tkt_yoy,
crescimento_yoy_pct=crescimento_pct,
qtd_vendas_yoy_pct=qtd_yoy_pct,
ticket_medio_yoy_pct=tkt_yoy_pct,
start_date=start_date,
end_date=end_date,
start_date_yoy=start_yoy,
end_date_yoy=end_yoy,
)