|
|
|
|
@@ -4,6 +4,7 @@ from odoo import http
|
|
|
|
|
from odoo.exceptions import ValidationError
|
|
|
|
|
from odoo.http import request, Response
|
|
|
|
|
import base64
|
|
|
|
|
import calendar
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
import uuid
|
|
|
|
|
@@ -24,6 +25,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
|
|
|
|
|
get_compras,
|
|
|
|
|
get_lealtad,
|
|
|
|
|
get_propinas,
|
|
|
|
|
get_direccion,
|
|
|
|
|
)
|
|
|
|
|
from odoo.addons.skeen_monedero.lealtad import (
|
|
|
|
|
nivel_por_codigo as lealtad_nivel_por_codigo,
|
|
|
|
|
@@ -7991,6 +7993,752 @@ class SkeenFrontendController(http.Controller):
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# Tablero de dirección (indicadores del dueño)
|
|
|
|
|
#
|
|
|
|
|
# Todas las cifras se calculan con los MISMOS criterios que los reportes
|
|
|
|
|
# existentes: ventas con state != 'cancelled' (igual que /reports/sales y
|
|
|
|
|
# /reports/concentrado), cobros de pagos state='completed' (igual que
|
|
|
|
|
# /reports/cash), no-show y ocupación con las reglas de /reports/kpis,
|
|
|
|
|
# recursos con /reports/recursos y franjas con /reports/horas-agenda.
|
|
|
|
|
# Es un tablero de SOLO LECTURA; requiere el permiso `direccion.ver`.
|
|
|
|
|
# ============================================================
|
|
|
|
|
DIRECCION_MAX_COHORTES = 12
|
|
|
|
|
|
|
|
|
|
def _dir_minus_year(self, d):
|
|
|
|
|
"""Mismo día del año anterior (29-feb cae al 28 en años no bisiestos)."""
|
|
|
|
|
try:
|
|
|
|
|
return d.replace(year=d.year - 1)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return d.replace(year=d.year - 1, day=28)
|
|
|
|
|
|
|
|
|
|
def _dir_add_months(self, d, n):
|
|
|
|
|
y = d.year + (d.month - 1 + n) // 12
|
|
|
|
|
m = (d.month - 1 + n) % 12 + 1
|
|
|
|
|
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
|
|
|
|
|
|
|
|
|
|
def _dir_mes_key(self, d):
|
|
|
|
|
return d.strftime('%Y-%m')
|
|
|
|
|
|
|
|
|
|
def _dir_domain_sucursal(self, modelo, sucursal):
|
|
|
|
|
"""Filtro por sucursal. Las ventas y los pagos no guardan sucursal
|
|
|
|
|
propia: se atribuyen a la de su cita origen (`cita_id.branch`)."""
|
|
|
|
|
if not sucursal:
|
|
|
|
|
return []
|
|
|
|
|
if modelo == 'skeen.cita':
|
|
|
|
|
return [('branch', '=', sucursal)]
|
|
|
|
|
if modelo in ('skeen.venta', 'skeen.pago'):
|
|
|
|
|
return [('cita_id.branch', '=', sucursal)]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def _dir_periodo_anterior(self, start, end):
|
|
|
|
|
d1 = datetime.strptime(start, '%Y-%m-%d').date()
|
|
|
|
|
d2 = datetime.strptime(end, '%Y-%m-%d').date()
|
|
|
|
|
dias = (d2 - d1).days + 1
|
|
|
|
|
prev_fin = d1 - timedelta(days=1)
|
|
|
|
|
prev_ini = prev_fin - timedelta(days=dias - 1)
|
|
|
|
|
return prev_ini.strftime('%Y-%m-%d'), prev_fin.strftime('%Y-%m-%d')
|
|
|
|
|
|
|
|
|
|
def _dir_periodo_anio(self, start, end):
|
|
|
|
|
d1 = datetime.strptime(start, '%Y-%m-%d').date()
|
|
|
|
|
d2 = datetime.strptime(end, '%Y-%m-%d').date()
|
|
|
|
|
return (self._dir_minus_year(d1).strftime('%Y-%m-%d'),
|
|
|
|
|
self._dir_minus_year(d2).strftime('%Y-%m-%d'))
|
|
|
|
|
|
|
|
|
|
def _dir_kpis(self, start, end, sucursal):
|
|
|
|
|
"""Cifras del resumen del periodo (y de su comparativo)."""
|
|
|
|
|
V = request.env['skeen.venta'].sudo()
|
|
|
|
|
C = request.env['skeen.cita'].sudo()
|
|
|
|
|
P = request.env['skeen.pago'].sudo()
|
|
|
|
|
vdom = [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)]
|
|
|
|
|
vdom += self._dir_domain_sucursal('skeen.venta', sucursal)
|
|
|
|
|
vgrp = V.read_group(vdom, ['total:sum', 'amount_paid:sum', 'amount_due:sum', 'propina:sum'], [])
|
|
|
|
|
vrow = vgrp[0] if vgrp else {}
|
|
|
|
|
num_ventas = vrow.get('__count') or vrow.get('total_count') or 0
|
|
|
|
|
ventas_total = vrow.get('total') or 0.0
|
|
|
|
|
pagado_ventas = vrow.get('amount_paid') or 0.0
|
|
|
|
|
propinas = vrow.get('propina') or 0.0
|
|
|
|
|
pdom = [('payment_date', '>=', f'{start} 00:00:00'),
|
|
|
|
|
('payment_date', '<=', f'{end} 23:59:59'),
|
|
|
|
|
('state', '=', 'completed')]
|
|
|
|
|
pdom += self._dir_domain_sucursal('skeen.pago', sucursal)
|
|
|
|
|
pgrp = P.read_group(pdom, ['amount:sum'], [])
|
|
|
|
|
cobros = (pgrp[0].get('amount') or 0.0) if pgrp else 0.0
|
|
|
|
|
cdom = [('date', '>=', start), ('date', '<=', end)]
|
|
|
|
|
cdom += self._dir_domain_sucursal('skeen.cita', sucursal)
|
|
|
|
|
estados = {}
|
|
|
|
|
for g in C.read_group(cdom, ['state'], ['state']):
|
|
|
|
|
estados[g['state']] = g.get('__count') or g.get('state_count') or 0
|
|
|
|
|
total_citas = sum(estados.values())
|
|
|
|
|
canceladas = estados.get('cancelled', 0)
|
|
|
|
|
no_show = estados.get('no_show', 0)
|
|
|
|
|
atendidas = estados.get('done', 0)
|
|
|
|
|
activas = total_citas - canceladas
|
|
|
|
|
# Pacientes activos y pacientes nuevos ("primera vez"). La definición de
|
|
|
|
|
# "primera vez" es EXACTAMENTE la de /reports/kpis: una cita del periodo
|
|
|
|
|
# cuya fecha es la primera cita histórica no cancelada del paciente.
|
|
|
|
|
# Se resuelve con consultas agrupadas para no traer miles de citas.
|
|
|
|
|
cr = request.env.cr
|
|
|
|
|
params = [start, end]
|
|
|
|
|
suc_sql = ' AND branch = %s' if sucursal else ''
|
|
|
|
|
if sucursal:
|
|
|
|
|
params.append(sucursal)
|
|
|
|
|
cr.execute("SELECT COUNT(DISTINCT partner_id) FROM skeen_cita "
|
|
|
|
|
"WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql,
|
|
|
|
|
params)
|
|
|
|
|
fila = cr.fetchone()
|
|
|
|
|
pacientes = fila[0] if fila else 0
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM skeen_cita c "
|
|
|
|
|
"JOIN (SELECT partner_id, MIN(date) AS f FROM skeen_cita "
|
|
|
|
|
" WHERE state <> 'cancelled' GROUP BY partner_id) m "
|
|
|
|
|
" ON m.partner_id = c.partner_id "
|
|
|
|
|
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s "
|
|
|
|
|
" AND c.date = m.f" + (' AND c.branch = %s' if sucursal else ''),
|
|
|
|
|
params)
|
|
|
|
|
fila = cr.fetchone()
|
|
|
|
|
primera = fila[0] if fila else 0
|
|
|
|
|
return {
|
|
|
|
|
'ingresos_cobrados': round(cobros, 2),
|
|
|
|
|
'ventas_total': round(ventas_total, 2),
|
|
|
|
|
'ventas_count': num_ventas,
|
|
|
|
|
'pagado_en_ventas': round(pagado_ventas, 2),
|
|
|
|
|
'ticket_promedio': round(ventas_total / num_ventas, 2) if num_ventas else 0.0,
|
|
|
|
|
'propinas': round(propinas, 2),
|
|
|
|
|
'visitas_atendidas': atendidas,
|
|
|
|
|
'citas_total': total_citas,
|
|
|
|
|
'citas_activas': activas,
|
|
|
|
|
'pacientes_activos': pacientes,
|
|
|
|
|
'pacientes_nuevos': primera,
|
|
|
|
|
'pacientes_recurrentes': max(activas - primera, 0),
|
|
|
|
|
'tasa_no_show': round(no_show / activas, 4) if activas else 0.0,
|
|
|
|
|
'tasa_cancelacion': round(canceladas / total_citas, 4) if total_citas else 0.0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _dir_variacion(self, actual, anterior):
|
|
|
|
|
if anterior in (None, 0):
|
|
|
|
|
return None
|
|
|
|
|
return round((actual - anterior) / anterior, 4)
|
|
|
|
|
|
|
|
|
|
def _dir_clientes(self, start, end, sucursal):
|
|
|
|
|
"""Pacientes activos, nuevos por mes, cohortes de retención, frecuencia,
|
|
|
|
|
valor por cliente y clientes en riesgo."""
|
|
|
|
|
cr = request.env.cr
|
|
|
|
|
cfg = get_direccion(request.env)
|
|
|
|
|
# Pacientes activos y citas del periodo, con una sola consulta agrupada.
|
|
|
|
|
params = [start, end]
|
|
|
|
|
suc_sql = ''
|
|
|
|
|
if sucursal:
|
|
|
|
|
suc_sql = ' AND branch = %s'
|
|
|
|
|
params.append(sucursal)
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT COUNT(*), COUNT(DISTINCT partner_id) FROM skeen_cita "
|
|
|
|
|
"WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, params)
|
|
|
|
|
n_citas_periodo, n_pac_periodo = cr.fetchone()
|
|
|
|
|
n_citas_periodo = n_citas_periodo or 0
|
|
|
|
|
n_pac_periodo = n_pac_periodo or 0
|
|
|
|
|
frecuencia_periodo = (round(n_citas_periodo / n_pac_periodo, 2)
|
|
|
|
|
if n_pac_periodo else 0.0)
|
|
|
|
|
# Primera y última visita + visitas por paciente (histórico). Una sola
|
|
|
|
|
# consulta agrupada: con el volumen real (52k citas) el ORM por separado
|
|
|
|
|
# (tres read_group) costaba la mayor parte del tiempo del tablero.
|
|
|
|
|
cr.execute("SELECT partner_id, MIN(date), MAX(date), COUNT(*) FROM skeen_cita "
|
|
|
|
|
"WHERE state <> 'cancelled' GROUP BY partner_id")
|
|
|
|
|
mins, maxs, conteos = {}, {}, {}
|
|
|
|
|
total_citas_hist = 0
|
|
|
|
|
for pid, d_min, d_max, n_vis in cr.fetchall():
|
|
|
|
|
mins[pid] = d_min
|
|
|
|
|
maxs[pid] = d_max
|
|
|
|
|
conteos[pid] = n_vis or 0
|
|
|
|
|
total_citas_hist += n_vis or 0
|
|
|
|
|
frecuencia_historica = (round(total_citas_hist / len(mins), 2) if mins else 0.0)
|
|
|
|
|
# Nuevos por mes (últimos 12 meses)
|
|
|
|
|
hoy = date.today()
|
|
|
|
|
meses = [self._dir_mes_key(self._dir_add_months(hoy.replace(day=1), -i))
|
|
|
|
|
for i in range(11, -1, -1)]
|
|
|
|
|
nuevos_mes = {m: 0 for m in meses}
|
|
|
|
|
for pid, d in mins.items():
|
|
|
|
|
key = self._dir_mes_key(d)
|
|
|
|
|
if key in nuevos_mes:
|
|
|
|
|
nuevos_mes[key] += 1
|
|
|
|
|
# Retención por cohortes (últimas 12 cohortes de primera visita)
|
|
|
|
|
cohort_ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) in nuevos_mes]
|
|
|
|
|
citas_cohorte = {}
|
|
|
|
|
if cohort_ids:
|
|
|
|
|
cr.execute("SELECT partner_id, date FROM skeen_cita "
|
|
|
|
|
"WHERE state <> 'cancelled' AND partner_id = ANY(%s) "
|
|
|
|
|
"ORDER BY partner_id, date", (cohort_ids,))
|
|
|
|
|
for pid, d_fecha in cr.fetchall():
|
|
|
|
|
citas_cohorte.setdefault(pid, []).append(d_fecha)
|
|
|
|
|
retencion = []
|
|
|
|
|
for m in meses:
|
|
|
|
|
ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) == m]
|
|
|
|
|
fila = {'cohorte': m, 'tamano': len(ids)}
|
|
|
|
|
for k in (1, 3, 6, 12):
|
|
|
|
|
clave = 'ret%d' % k
|
|
|
|
|
if not ids:
|
|
|
|
|
fila[clave] = 0
|
|
|
|
|
fila[clave + '_tasa'] = None
|
|
|
|
|
continue
|
|
|
|
|
primero = min(mins[pid] for pid in ids)
|
|
|
|
|
maduro = self._dir_add_months(primero, k) <= hoy
|
|
|
|
|
ret = 0
|
|
|
|
|
for pid in ids:
|
|
|
|
|
tope = self._dir_add_months(mins[pid], k)
|
|
|
|
|
if any(d > mins[pid] and d <= tope for d in citas_cohorte.get(pid, [])):
|
|
|
|
|
ret += 1
|
|
|
|
|
fila[clave] = ret
|
|
|
|
|
fila[clave + '_tasa'] = round(ret / len(ids), 4) if maduro else None
|
|
|
|
|
retencion.append(fila)
|
|
|
|
|
# Valor por cliente (ingreso histórico por paciente, ventas no canceladas)
|
|
|
|
|
cr.execute("SELECT SUM(total) FROM skeen_venta "
|
|
|
|
|
"WHERE state <> 'cancelled' GROUP BY partner_id")
|
|
|
|
|
valores = sorted(float(r[0] or 0.0) for r in cr.fetchall())
|
|
|
|
|
valor_prom = round(sum(valores) / len(valores), 2) if valores else 0.0
|
|
|
|
|
valor_mediana = 0.0
|
|
|
|
|
if valores:
|
|
|
|
|
n = len(valores)
|
|
|
|
|
mitad = n // 2
|
|
|
|
|
valor_mediana = round((valores[mitad] if n % 2 else
|
|
|
|
|
(valores[mitad - 1] + valores[mitad]) / 2.0), 2)
|
|
|
|
|
# Clientes en riesgo
|
|
|
|
|
dias = cfg['dias_inactividad']
|
|
|
|
|
visitas_min = cfg['visitas_minimas_riesgo']
|
|
|
|
|
limite = hoy - timedelta(days=dias)
|
|
|
|
|
riesgo = []
|
|
|
|
|
for pid, ultimo in maxs.items():
|
|
|
|
|
if ultimo and ultimo <= limite and conteos.get(pid, 0) >= visitas_min:
|
|
|
|
|
riesgo.append((pid, ultimo, conteos.get(pid, 0),
|
|
|
|
|
hoy - ultimo if ultimo else 0))
|
|
|
|
|
riesgo.sort(key=lambda x: (-x[2], x[1]))
|
|
|
|
|
total_riesgo = len(riesgo)
|
|
|
|
|
top = riesgo[:100]
|
|
|
|
|
partners = request.env['res.partner'].sudo().browse([r[0] for r in top])
|
|
|
|
|
riesgo_rows = []
|
|
|
|
|
for partner in partners:
|
|
|
|
|
ultimo = maxs.get(partner.id)
|
|
|
|
|
riesgo_rows.append({
|
|
|
|
|
'id': partner.id,
|
|
|
|
|
'name': partner.name,
|
|
|
|
|
'phone': partner.phone or partner.mobile or partner.whatsapp or '',
|
|
|
|
|
'ultima_visita': ultimo.strftime('%Y-%m-%d') if ultimo else None,
|
|
|
|
|
'visitas': conteos.get(partner.id, 0),
|
|
|
|
|
'dias_sin_venir': (hoy - ultimo).days if ultimo else None,
|
|
|
|
|
})
|
|
|
|
|
return {
|
|
|
|
|
'activos': n_pac_periodo,
|
|
|
|
|
'frecuencia_promedio': frecuencia_periodo,
|
|
|
|
|
'frecuencia_historica': frecuencia_historica,
|
|
|
|
|
'nuevos_por_mes': [{'mes': m, 'total': nuevos_mes[m]} for m in meses],
|
|
|
|
|
'retencion_cohortes': retencion,
|
|
|
|
|
'valor_por_cliente': {
|
|
|
|
|
'promedio': valor_prom,
|
|
|
|
|
'mediana': valor_mediana,
|
|
|
|
|
'total': round(sum(valores), 2),
|
|
|
|
|
'pacientes_con_ventas': len(valores),
|
|
|
|
|
},
|
|
|
|
|
'en_riesgo': {
|
|
|
|
|
'total': total_riesgo,
|
|
|
|
|
'dias_inactividad': dias,
|
|
|
|
|
'visitas_minimas': visitas_min,
|
|
|
|
|
'pacientes': riesgo_rows,
|
|
|
|
|
'filtro_campana': {'ultima_visita_dias': dias},
|
|
|
|
|
'link_campana': '/campanas',
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _dir_ocupacion(self, start, end, sucursal):
|
|
|
|
|
"""Horas vendidas vs disponibles (reglas de /reports/kpis), recursos
|
|
|
|
|
(reglas de /reports/recursos) y franjas horarias (/reports/horas-agenda)."""
|
|
|
|
|
cr = request.env.cr
|
|
|
|
|
d1 = datetime.strptime(start, '%Y-%m-%d').date()
|
|
|
|
|
d2 = datetime.strptime(end, '%Y-%m-%d').date()
|
|
|
|
|
dias_habiles = sum(1 for i in range((d2 - d1).days + 1)
|
|
|
|
|
if (d1 + timedelta(days=i)).weekday() != 6)
|
|
|
|
|
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(
|
|
|
|
|
[('date', '>=', start), ('date', '<=', end)])
|
|
|
|
|
bloqueo_horas = {}
|
|
|
|
|
for b in bloqueos:
|
|
|
|
|
if not b.doctor_id or b.date.weekday() == 6:
|
|
|
|
|
continue
|
|
|
|
|
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
|
|
|
|
|
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
|
|
|
|
|
params = [start, end]
|
|
|
|
|
suc_sql = ' AND c.branch = %s' if sucursal else ''
|
|
|
|
|
if sucursal:
|
|
|
|
|
params.append(sucursal)
|
|
|
|
|
# Ocupación por médico / no-show por médico (reglas de /reports/kpis).
|
|
|
|
|
# El cálculo por horas se hace en SQL agrupado: con rangos largos el
|
|
|
|
|
# ORM por cita era el cuello de botella del tablero.
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT c.doctor_id, COALESCE(e.name, 'Sin médico'), "
|
|
|
|
|
" SUM(COALESCE(s.duration_min, 30)), COUNT(*), "
|
|
|
|
|
" COUNT(*) FILTER (WHERE c.state = 'no_show') "
|
|
|
|
|
"FROM skeen_cita c "
|
|
|
|
|
"LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
|
|
|
|
|
"LEFT JOIN hr_employee e ON e.id = c.doctor_id "
|
|
|
|
|
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s"
|
|
|
|
|
+ suc_sql + " GROUP BY c.doctor_id, e.name", params)
|
|
|
|
|
medicos = []
|
|
|
|
|
no_show_medico = {}
|
|
|
|
|
for doctor_id, doc, minutos, n_citas, n_ns in cr.fetchall():
|
|
|
|
|
no_show_medico[doc] = {'citas': n_citas or 0, 'no_shows': n_ns or 0}
|
|
|
|
|
if not doctor_id:
|
|
|
|
|
continue # /reports/kpis no cuenta horas de citas sin médico
|
|
|
|
|
vendidas = (minutos or 0) / 60.0
|
|
|
|
|
disponibles = max(dias_habiles * 8.0 - bloqueo_horas.get(doc, 0.0), 0.0)
|
|
|
|
|
medicos.append({
|
|
|
|
|
'medico': doc,
|
|
|
|
|
'horas_vendidas': round(vendidas, 2),
|
|
|
|
|
'horas_disponibles': round(disponibles, 2),
|
|
|
|
|
'horas_libres': round(max(disponibles - vendidas, 0.0), 2),
|
|
|
|
|
'ocupacion': round(min(vendidas / disponibles, 1.0), 4) if disponibles else 0.0,
|
|
|
|
|
})
|
|
|
|
|
medicos.sort(key=lambda x: -x['ocupacion'])
|
|
|
|
|
no_show_rows = [{
|
|
|
|
|
'medico': k,
|
|
|
|
|
'citas': v['citas'],
|
|
|
|
|
'no_shows': v['no_shows'],
|
|
|
|
|
'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0.0,
|
|
|
|
|
} for k, v in no_show_medico.items()]
|
|
|
|
|
no_show_rows.sort(key=lambda x: -x['tasa'])
|
|
|
|
|
# Ocupación por sucursal (mismas citas; capacidad = jornada de la agenda).
|
|
|
|
|
# La duración usa la regla de `_duracion()`: cita > servicio > 30 min.
|
|
|
|
|
ini_h, fin_h = self._agenda_bounds()
|
|
|
|
|
jornada = max(0.0, fin_h - ini_h)
|
|
|
|
|
disponibles_suc = dias_habiles * jornada
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT c.branch, SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) "
|
|
|
|
|
"FROM skeen_cita c LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
|
|
|
|
|
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s"
|
|
|
|
|
+ suc_sql + " GROUP BY c.branch", params)
|
|
|
|
|
por_sucursal = {br or 'sin_sucursal': (minutos or 0) / 60.0
|
|
|
|
|
for br, minutos in cr.fetchall()}
|
|
|
|
|
sucursales = [{
|
|
|
|
|
'sucursal': k,
|
|
|
|
|
'horas_vendidas': round(v, 2),
|
|
|
|
|
'horas_disponibles': round(disponibles_suc, 2),
|
|
|
|
|
'ocupacion': round(min(v / disponibles_suc, 1.0), 4) if disponibles_suc else 0.0,
|
|
|
|
|
} for k, v in por_sucursal.items()]
|
|
|
|
|
sucursales.sort(key=lambda x: -x['horas_vendidas'])
|
|
|
|
|
# Ocupación por recurso (igual que /reports/recursos)
|
|
|
|
|
horas_dia = jornada
|
|
|
|
|
disponibles_rec = horas_dia * ((d2 - d1).days + 1)
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT rel.recurso_id, COUNT(*), "
|
|
|
|
|
" SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) "
|
|
|
|
|
"FROM skeen_cita c "
|
|
|
|
|
"JOIN skeen_cita_recurso_rel rel ON rel.cita_id = c.id "
|
|
|
|
|
"LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
|
|
|
|
|
"WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') "
|
|
|
|
|
" AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY rel.recurso_id",
|
|
|
|
|
params)
|
|
|
|
|
acumulado = {rid: {'citas': n or 0, 'minutos': m or 0}
|
|
|
|
|
for rid, n, m in cr.fetchall()}
|
|
|
|
|
# Franjas horarias (/reports/horas-agenda)
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT CAST(FLOOR(c.time) AS INT), c.state, COUNT(*) FROM skeen_cita c "
|
|
|
|
|
"WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') "
|
|
|
|
|
" AND c.date >= %s AND c.date <= %s" + suc_sql +
|
|
|
|
|
" GROUP BY 1, 2", params)
|
|
|
|
|
franjas = {}
|
|
|
|
|
for hora, estado, n in cr.fetchall():
|
|
|
|
|
fr = franjas.setdefault(int(hora), {'total': 0, 'by_state': {}})
|
|
|
|
|
fr['total'] += n or 0
|
|
|
|
|
fr['by_state'][estado] = fr['by_state'].get(estado, 0) + (n or 0)
|
|
|
|
|
recursos = request.env['skeen.recurso'].sudo().search(
|
|
|
|
|
[('activo', '=', True)], order='sequence, name')
|
|
|
|
|
filas_rec = []
|
|
|
|
|
for r in recursos:
|
|
|
|
|
acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0})
|
|
|
|
|
horas = round(acc['minutos'] / 60.0, 2)
|
|
|
|
|
ocup = round(horas / disponibles_rec * 100.0, 1) if disponibles_rec else 0.0
|
|
|
|
|
filas_rec.append({
|
|
|
|
|
'recurso_id': r.id,
|
|
|
|
|
'recurso': r.name,
|
|
|
|
|
'citas': acc['citas'],
|
|
|
|
|
'horas_ocupadas': horas,
|
|
|
|
|
'horas_disponibles': round(disponibles_rec, 2),
|
|
|
|
|
'ocupacion_pct': min(ocup, 100.0),
|
|
|
|
|
})
|
|
|
|
|
filas_hora = []
|
|
|
|
|
for hora in range(8, 20):
|
|
|
|
|
entry = franjas.get(hora, {'total': 0, 'by_state': {}})
|
|
|
|
|
filas_hora.append({'hora': f'{hora:02d}:00', 'total': entry['total'],
|
|
|
|
|
'by_state': entry['by_state']})
|
|
|
|
|
con_citas = [f for f in filas_hora if f['total'] > 0]
|
|
|
|
|
mas = sorted(con_citas, key=lambda x: -x['total'])[:3]
|
|
|
|
|
menos = sorted(filas_hora, key=lambda x: x['total'])[:3]
|
|
|
|
|
return {
|
|
|
|
|
'dias_habiles': dias_habiles,
|
|
|
|
|
'jornada_horas': round(jornada, 2),
|
|
|
|
|
'por_medico': medicos,
|
|
|
|
|
'no_show_por_medico': no_show_rows,
|
|
|
|
|
'por_sucursal': sucursales,
|
|
|
|
|
'por_recurso': filas_rec,
|
|
|
|
|
'franjas': filas_hora,
|
|
|
|
|
'franjas_mas_ocupadas': mas,
|
|
|
|
|
'franjas_menos_ocupadas': menos,
|
|
|
|
|
'total_horas_vendidas': round(sum(m['horas_vendidas'] for m in medicos), 2),
|
|
|
|
|
'total_horas_ocupadas_recursos': round(sum(f['horas_ocupadas'] for f in filas_rec), 2),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _dir_category_label(self, servicio, code):
|
|
|
|
|
"""Etiqueta de la categoría de un servicio (la selección puede ser una
|
|
|
|
|
lista o un método, según cómo esté definida)."""
|
|
|
|
|
if not servicio or not code:
|
|
|
|
|
return ''
|
|
|
|
|
try:
|
|
|
|
|
sel = servicio._fields['category'].selection
|
|
|
|
|
if isinstance(sel, str):
|
|
|
|
|
sel = getattr(servicio, sel)()
|
|
|
|
|
elif callable(sel):
|
|
|
|
|
sel = sel(servicio)
|
|
|
|
|
return dict(sel).get(code, '') or ''
|
|
|
|
|
except Exception:
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
|
|
def _dir_ingresos(self, start, end, sucursal):
|
|
|
|
|
"""Ingresos por servicio, categoría, sucursal, método de pago, propinas,
|
|
|
|
|
membresías, paquetes y pasivo de lealtad."""
|
|
|
|
|
P = request.env['skeen.pago'].sudo()
|
|
|
|
|
cr = request.env.cr
|
|
|
|
|
params = [start, end]
|
|
|
|
|
suc_sql = ' AND c.branch = %s' if sucursal else ''
|
|
|
|
|
if sucursal:
|
|
|
|
|
params.append(sucursal)
|
|
|
|
|
# Por servicio y por categoría: líneas de las ventas del periodo, en una
|
|
|
|
|
# sola consulta agrupada (no se traen las ventas al proceso).
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT l.service_id, SUM(l.subtotal), SUM(l.quantity) "
|
|
|
|
|
"FROM skeen_venta_line l JOIN skeen_venta v ON v.id = l.venta_id "
|
|
|
|
|
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
|
|
|
|
|
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
|
|
|
|
|
+ suc_sql + " GROUP BY l.service_id", params)
|
|
|
|
|
servicios = [{
|
|
|
|
|
'service_id': sid or 0,
|
|
|
|
|
'servicio': 'Sin servicio',
|
|
|
|
|
'cantidad': float(qty or 0.0),
|
|
|
|
|
'total': round(float(subtotal or 0.0), 2),
|
|
|
|
|
} for sid, subtotal, qty in cr.fetchall()]
|
|
|
|
|
categorias = {}
|
|
|
|
|
svc_ids = [s['service_id'] for s in servicios if s['service_id']]
|
|
|
|
|
svc_map = {x.id: x for x in request.env['skeen.servicio'].sudo().browse(svc_ids)}
|
|
|
|
|
for s in servicios:
|
|
|
|
|
svc = svc_map.get(s['service_id'])
|
|
|
|
|
s['servicio'] = svc.name if svc else 'Sin servicio'
|
|
|
|
|
code = svc.category if svc else ''
|
|
|
|
|
label = self._dir_category_label(svc, code) if svc else ''
|
|
|
|
|
cat = categorias.setdefault(code or 'sin', {'categoria': label or 'Sin categoría',
|
|
|
|
|
'total': 0.0, 'cantidad': 0.0})
|
|
|
|
|
cat['total'] += s['total']
|
|
|
|
|
cat['cantidad'] += s['cantidad']
|
|
|
|
|
servicios.sort(key=lambda x: -x['total'])
|
|
|
|
|
# Por sucursal (la sucursal sale de la cita origen de la venta)
|
|
|
|
|
cr.execute(
|
|
|
|
|
"SELECT c.branch, SUM(v.total), COUNT(*) FROM skeen_venta v "
|
|
|
|
|
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
|
|
|
|
|
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
|
|
|
|
|
+ suc_sql + " GROUP BY c.branch", params)
|
|
|
|
|
por_sucursal = {}
|
|
|
|
|
for branch, total, n in cr.fetchall():
|
|
|
|
|
por_sucursal[branch or 'sin_sucursal'] = {
|
|
|
|
|
'total': float(total or 0.0), 'ventas': n or 0}
|
|
|
|
|
# Por método de pago (igual que /reports/cash)
|
|
|
|
|
pdom = [('payment_date', '>=', f'{start} 00:00:00'),
|
|
|
|
|
('payment_date', '<=', f'{end} 23:59:59'),
|
|
|
|
|
('state', '=', 'completed')]
|
|
|
|
|
pdom += self._dir_domain_sucursal('skeen.pago', sucursal)
|
|
|
|
|
por_metodo = {}
|
|
|
|
|
for g in P.read_group(pdom, ['payment_method', 'amount:sum'], ['payment_method']):
|
|
|
|
|
por_metodo[g['payment_method'] or 'otro'] = round(g.get('amount') or 0.0, 2)
|
|
|
|
|
# Membresías
|
|
|
|
|
Memb = request.env['skeen.membresia'].sudo()
|
|
|
|
|
activas_grp = Memb.read_group([('estado', 'in', ('activa', 'congelada'))],
|
|
|
|
|
['plan_id'], ['plan_id'])
|
|
|
|
|
FACTOR = {'semanal': 52 / 12.0, 'mensual': 1.0, 'trimestral': 1 / 3.0,
|
|
|
|
|
'semestral': 1 / 6.0, 'anual': 1 / 12.0}
|
|
|
|
|
mrr = 0.0
|
|
|
|
|
for g in activas_grp:
|
|
|
|
|
if not g.get('plan_id'):
|
|
|
|
|
continue
|
|
|
|
|
plan = request.env['skeen.membresia.plan'].sudo().browse(g['plan_id'][0])
|
|
|
|
|
n = g.get('__count') or g.get('plan_id_count') or 0
|
|
|
|
|
mrr += n * (plan.precio or 0.0) * FACTOR.get(plan.periodicidad, 1.0)
|
|
|
|
|
Periodo = request.env['skeen.membresia.periodo'].sudo()
|
|
|
|
|
recurrentes = Periodo.read_group([
|
|
|
|
|
('estado', '=', 'pagado'), ('tipo', '=', 'periodo'),
|
|
|
|
|
('fecha_pago', '>=', f'{start} 00:00:00'), ('fecha_pago', '<=', f'{end} 23:59:59')],
|
|
|
|
|
['monto:sum'], [])
|
|
|
|
|
membresias = {
|
|
|
|
|
'activas': sum((g.get('__count') or g.get('plan_id_count') or 0)
|
|
|
|
|
for g in activas_grp),
|
|
|
|
|
'mrr_estimado': round(mrr, 2),
|
|
|
|
|
'renovaciones': sum((g.get('__count') or 0) for g in recurrentes),
|
|
|
|
|
'ingreso_recurrente_periodo': round((recurrentes[0].get('monto') or 0.0)
|
|
|
|
|
if recurrentes else 0.0, 2),
|
|
|
|
|
'altas': Memb.search_count([('fecha_inicio', '>=', start),
|
|
|
|
|
('fecha_inicio', '<=', end)]),
|
|
|
|
|
'cancelaciones': Memb.search_count([
|
|
|
|
|
('estado', '=', 'cancelada'),
|
|
|
|
|
('fecha_cancelacion', '>=', f'{start} 00:00:00'),
|
|
|
|
|
('fecha_cancelacion', '<=', f'{end} 23:59:59')]),
|
|
|
|
|
}
|
|
|
|
|
# Paquetes vendidos y sesiones pendientes (compromiso del negocio)
|
|
|
|
|
PC = request.env['skeen.paquete.compra'].sudo()
|
|
|
|
|
pv = PC.read_group([('date', '>=', start), ('date', '<=', end),
|
|
|
|
|
('state', '!=', 'cancelled')], ['price:sum'], [])
|
|
|
|
|
ses = PC.read_group([('state', '!=', 'cancelled')],
|
|
|
|
|
['sessions_total:sum', 'sessions_used:sum'], [])
|
|
|
|
|
paquetes = {
|
|
|
|
|
'vendidos': (PC.search_count([('date', '>=', start), ('date', '<=', end),
|
|
|
|
|
('state', '!=', 'cancelled')])),
|
|
|
|
|
'total_vendido': round((pv[0].get('price') or 0.0) if pv else 0.0, 2),
|
|
|
|
|
'activos': PC.search_count([('state', '=', 'active')]),
|
|
|
|
|
'sesiones_pendientes': int(((ses[0].get('sessions_total') or 0) -
|
|
|
|
|
(ses[0].get('sessions_used') or 0)) if ses else 0),
|
|
|
|
|
}
|
|
|
|
|
# Lealtad: pasivo en pesos (igual que /reports/lealtad)
|
|
|
|
|
cfg_l = get_lealtad(request.env)
|
|
|
|
|
valor = float(cfg_l.get('valor_punto') or 1.0)
|
|
|
|
|
mon = request.env['skeen.monedero'].sudo().read_group([], ['points:sum'], [])
|
|
|
|
|
puntos = int((mon[0].get('points') or 0) if mon else 0)
|
|
|
|
|
Tx = request.env['skeen.monedero.transaction'].sudo()
|
|
|
|
|
by_type = {}
|
|
|
|
|
for g in Tx.read_group([('create_date', '>=', f'{start} 00:00:00'),
|
|
|
|
|
('create_date', '<=', f'{end} 23:59:59')],
|
|
|
|
|
['type', 'points:sum'], ['type']):
|
|
|
|
|
by_type[g['type']] = g['points']
|
|
|
|
|
lealtad = {
|
|
|
|
|
'valor_punto': valor,
|
|
|
|
|
'puntos_activos': puntos,
|
|
|
|
|
'pasivo_mxn': round(puntos * valor, 2),
|
|
|
|
|
'otorgados': by_type.get('accrual', 0) + by_type.get('deposito', 0),
|
|
|
|
|
'canjeados': abs(by_type.get('redemption', 0)),
|
|
|
|
|
'expirados': abs(by_type.get('expiration', 0)),
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
'por_servicio': servicios,
|
|
|
|
|
'por_categoria': sorted(categorias.values(), key=lambda x: -x['total']),
|
|
|
|
|
'por_sucursal': [{'sucursal': k, 'total': round(v['total'], 2),
|
|
|
|
|
'ventas': v['ventas']} for k, v in por_sucursal.items()],
|
|
|
|
|
'por_metodo_pago': por_metodo,
|
|
|
|
|
'membresias': membresias,
|
|
|
|
|
'paquetes': paquetes,
|
|
|
|
|
'lealtad': lealtad,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _dir_metas(self, start, end, sucursal, kpis=None):
|
|
|
|
|
"""Metas por sucursal y por empleado, avance del mes y proyección."""
|
|
|
|
|
cfg = get_direccion(request.env)
|
|
|
|
|
V = request.env['skeen.venta'].sudo()
|
|
|
|
|
Lineas = request.env['skeen.venta.line'].sudo()
|
|
|
|
|
hoy = date.today()
|
|
|
|
|
d_fin = datetime.strptime(end, '%Y-%m-%d').date()
|
|
|
|
|
mes_ini = d_fin.replace(day=1)
|
|
|
|
|
mes_fin = d_fin.replace(day=calendar.monthrange(d_fin.year, d_fin.month)[1])
|
|
|
|
|
dias_mes = (mes_fin - mes_ini).days + 1
|
|
|
|
|
dias_transcurridos = min((min(hoy, mes_fin) - mes_ini).days + 1, dias_mes)
|
|
|
|
|
if dias_transcurridos < 1:
|
|
|
|
|
dias_transcurridos = 1
|
|
|
|
|
mes_cerrado = hoy > mes_fin
|
|
|
|
|
|
|
|
|
|
def proyectar(actual):
|
|
|
|
|
return round(actual, 2) if mes_cerrado else round(actual / dias_transcurridos * dias_mes, 2)
|
|
|
|
|
|
|
|
|
|
# Metas por sucursal
|
|
|
|
|
metas = {m['sucursal']: m['meta_mensual'] for m in cfg['metas_sucursal']}
|
|
|
|
|
sucursales = request.env['skeen.sucursal'].sudo().search([], order='sequence, name')
|
|
|
|
|
codigos = [s.code for s in sucursales if s.code]
|
|
|
|
|
for code in metas:
|
|
|
|
|
if code not in codigos:
|
|
|
|
|
codigos.append(code)
|
|
|
|
|
filas_suc = []
|
|
|
|
|
for code in codigos:
|
|
|
|
|
grp = V.read_group([('state', '!=', 'cancelled'),
|
|
|
|
|
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
|
|
|
|
|
('date', '<=', mes_fin.strftime('%Y-%m-%d')),
|
|
|
|
|
('cita_id.branch', '=', code)], ['total:sum'], [])
|
|
|
|
|
actual = round((grp[0].get('total') or 0.0) if grp else 0.0, 2)
|
|
|
|
|
meta = metas.get(code, 0.0)
|
|
|
|
|
filas_suc.append({
|
|
|
|
|
'sucursal': code,
|
|
|
|
|
'meta_mensual': round(meta, 2),
|
|
|
|
|
'ventas_mes': actual,
|
|
|
|
|
'pct': round(actual / meta, 4) if meta else None,
|
|
|
|
|
'proyeccion': proyectar(actual),
|
|
|
|
|
})
|
|
|
|
|
# Metas por empleado (ventas del mes atribuidas por línea recetada)
|
|
|
|
|
mes_ventas = V.search([('state', '!=', 'cancelled'),
|
|
|
|
|
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
|
|
|
|
|
('date', '<=', mes_fin.strftime('%Y-%m-%d'))])
|
|
|
|
|
vendido_emp = {}
|
|
|
|
|
if mes_ventas:
|
|
|
|
|
for g in Lineas.read_group([('venta_id', 'in', mes_ventas.ids),
|
|
|
|
|
('prescribed_by_id', '!=', False)],
|
|
|
|
|
['prescribed_by_id', 'subtotal:sum'],
|
|
|
|
|
['prescribed_by_id']):
|
|
|
|
|
vendido_emp[g['prescribed_by_id'][0]] = g.get('subtotal') or 0.0
|
|
|
|
|
Emp = request.env['hr.employee'].sudo()
|
|
|
|
|
empleados = Emp.search(['|', ('monthly_goal', '>', 0),
|
|
|
|
|
('id', 'in', list(vendido_emp.keys()))])
|
|
|
|
|
filas_emp = []
|
|
|
|
|
for e in empleados:
|
|
|
|
|
actual = round(vendido_emp.get(e.id, 0.0), 2)
|
|
|
|
|
meta = e.monthly_goal or 0.0
|
|
|
|
|
filas_emp.append({
|
|
|
|
|
'employee_id': e.id,
|
|
|
|
|
'empleado': e.name,
|
|
|
|
|
'meta_mensual': round(meta, 2),
|
|
|
|
|
'ventas_mes': actual,
|
|
|
|
|
'pct': round(actual / meta, 4) if meta else None,
|
|
|
|
|
'proyeccion': proyectar(actual),
|
|
|
|
|
})
|
|
|
|
|
filas_emp.sort(key=lambda x: -(x['ventas_mes']))
|
|
|
|
|
# Proyección del periodo seleccionado
|
|
|
|
|
p1 = datetime.strptime(start, '%Y-%m-%d').date()
|
|
|
|
|
p2 = d_fin
|
|
|
|
|
dias_periodo = (p2 - p1).days + 1
|
|
|
|
|
dias_trans_p = min((min(hoy, p2) - p1).days + 1, dias_periodo)
|
|
|
|
|
if dias_trans_p < 1:
|
|
|
|
|
dias_trans_p = 1
|
|
|
|
|
if kpis is None:
|
|
|
|
|
kpis = self._dir_kpis(start, end, sucursal)
|
|
|
|
|
periodo_cerrado = hoy > p2
|
|
|
|
|
proy_ventas = (kpis['ventas_total'] if periodo_cerrado
|
|
|
|
|
else round(kpis['ventas_total'] / dias_trans_p * dias_periodo, 2))
|
|
|
|
|
proy_cobros = (kpis['ingresos_cobrados'] if periodo_cerrado
|
|
|
|
|
else round(kpis['ingresos_cobrados'] / dias_trans_p * dias_periodo, 2))
|
|
|
|
|
# Mes actual (para metas)
|
|
|
|
|
mes_grp = V.read_group([('state', '!=', 'cancelled'),
|
|
|
|
|
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
|
|
|
|
|
('date', '<=', mes_fin.strftime('%Y-%m-%d'))],
|
|
|
|
|
['total:sum'], [])
|
|
|
|
|
ventas_mes = round((mes_grp[0].get('total') or 0.0) if mes_grp else 0.0, 2)
|
|
|
|
|
meta_global = cfg['meta_mensual_global'] or sum(metas.values())
|
|
|
|
|
return {
|
|
|
|
|
'por_sucursal': filas_suc,
|
|
|
|
|
'por_empleado': filas_emp,
|
|
|
|
|
'periodo': {
|
|
|
|
|
'dias': dias_periodo,
|
|
|
|
|
'dias_transcurridos': dias_trans_p,
|
|
|
|
|
'ventas': kpis['ventas_total'],
|
|
|
|
|
'cobros': kpis['ingresos_cobrados'],
|
|
|
|
|
'proyeccion_ventas': proy_ventas,
|
|
|
|
|
'proyeccion_cobros': proy_cobros,
|
|
|
|
|
},
|
|
|
|
|
'mes': {
|
|
|
|
|
'mes': self._dir_mes_key(mes_ini),
|
|
|
|
|
'dias_mes': dias_mes,
|
|
|
|
|
'dias_transcurridos': dias_transcurridos,
|
|
|
|
|
'ventas': ventas_mes,
|
|
|
|
|
'meta': round(meta_global, 2),
|
|
|
|
|
'pct': round(ventas_mes / meta_global, 4) if meta_global else None,
|
|
|
|
|
'proyeccion': proyectar(ventas_mes),
|
|
|
|
|
'proyeccion_vs_meta': (round(proyectar(ventas_mes) / meta_global, 4)
|
|
|
|
|
if meta_global else None),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@http.route('/skeen/frontend/v1/direccion', type='http', auth='none',
|
|
|
|
|
methods=['GET', 'OPTIONS'], csrf=False)
|
|
|
|
|
@require_role('lectura')
|
|
|
|
|
@require_perm('direccion.ver')
|
|
|
|
|
def direccion_tablero(self, **kw):
|
|
|
|
|
"""Tablero ejecutivo del dueño. Solo lectura."""
|
|
|
|
|
try:
|
|
|
|
|
if request.httprequest.method == 'OPTIONS':
|
|
|
|
|
return json_response({}, 200)
|
|
|
|
|
hoy = date.today().strftime('%Y-%m-%d')
|
|
|
|
|
start = kw.get('start') or hoy
|
|
|
|
|
end = kw.get('end') or hoy
|
|
|
|
|
try:
|
|
|
|
|
d1 = datetime.strptime(start, '%Y-%m-%d').date()
|
|
|
|
|
d2 = datetime.strptime(end, '%Y-%m-%d').date()
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
|
|
|
|
|
if d2 < d1:
|
|
|
|
|
d1, d2 = d2, d1
|
|
|
|
|
start, end = d1.strftime('%Y-%m-%d'), d2.strftime('%Y-%m-%d')
|
|
|
|
|
if (d2 - d1).days > 4000:
|
|
|
|
|
return json_response({'status': 'error',
|
|
|
|
|
'message': 'El rango no puede pasar de 4000 días'}, 400)
|
|
|
|
|
sucursal = (kw.get('sucursal') or '').strip() or None
|
|
|
|
|
cfg = get_direccion(request.env)
|
|
|
|
|
comparar = (kw.get('comparar') or cfg['comparativo_default'] or 'anterior').lower()
|
|
|
|
|
if comparar not in ('anterior', 'anio', 'ninguno'):
|
|
|
|
|
comparar = 'anterior'
|
|
|
|
|
t0 = datetime.now()
|
|
|
|
|
kpis = self._dir_kpis(start, end, sucursal)
|
|
|
|
|
# Serie diaria de ventas (mismo criterio que /reports/sales.by_day)
|
|
|
|
|
params = [start, end]
|
|
|
|
|
suc_sql = ' AND c.branch = %s' if sucursal else ''
|
|
|
|
|
if sucursal:
|
|
|
|
|
params.append(sucursal)
|
|
|
|
|
request.env.cr.execute(
|
|
|
|
|
"SELECT v.date, SUM(v.total) FROM skeen_venta v "
|
|
|
|
|
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
|
|
|
|
|
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
|
|
|
|
|
+ suc_sql + " GROUP BY v.date ORDER BY v.date", params)
|
|
|
|
|
by_day = {str(d)[:10]: round(float(t or 0.0), 2)
|
|
|
|
|
for d, t in request.env.cr.fetchall()}
|
|
|
|
|
kpis['por_dia'] = [{'date': k, 'total': by_day[k]} for k in sorted(by_day)]
|
|
|
|
|
resumen = kpis
|
|
|
|
|
comparativo = None
|
|
|
|
|
if comparar != 'ninguno':
|
|
|
|
|
if comparar == 'anio':
|
|
|
|
|
cs, ce = self._dir_periodo_anio(start, end)
|
|
|
|
|
else:
|
|
|
|
|
cs, ce = self._dir_periodo_anterior(start, end)
|
|
|
|
|
ck = self._dir_kpis(cs, ce, sucursal)
|
|
|
|
|
variaciones = {}
|
|
|
|
|
for campo in ('ingresos_cobrados', 'ventas_total', 'ticket_promedio',
|
|
|
|
|
'visitas_atendidas', 'pacientes_nuevos', 'pacientes_recurrentes',
|
|
|
|
|
'tasa_no_show', 'tasa_cancelacion'):
|
|
|
|
|
variaciones[campo] = self._dir_variacion(kpis.get(campo, 0), ck.get(campo, 0))
|
|
|
|
|
comparativo = {'modo': comparar, 'start': cs, 'end': ce,
|
|
|
|
|
'kpis': ck, 'variacion': variaciones}
|
|
|
|
|
t_sec = {'resumen': int((datetime.now() - t0).total_seconds() * 1000)}
|
|
|
|
|
t_a = datetime.now()
|
|
|
|
|
clientes = self._dir_clientes(start, end, sucursal)
|
|
|
|
|
t_b = datetime.now()
|
|
|
|
|
ocupacion = self._dir_ocupacion(start, end, sucursal)
|
|
|
|
|
t_c = datetime.now()
|
|
|
|
|
ingresos = self._dir_ingresos(start, end, sucursal)
|
|
|
|
|
t_d = datetime.now()
|
|
|
|
|
metas = self._dir_metas(start, end, sucursal, kpis=kpis)
|
|
|
|
|
t_e = datetime.now()
|
|
|
|
|
t_sec['clientes'] = int((t_b - t_a).total_seconds() * 1000)
|
|
|
|
|
t_sec['ocupacion'] = int((t_c - t_b).total_seconds() * 1000)
|
|
|
|
|
t_sec['ingresos'] = int((t_d - t_c).total_seconds() * 1000)
|
|
|
|
|
t_sec['metas'] = int((t_e - t_d).total_seconds() * 1000)
|
|
|
|
|
no_disponible = [
|
|
|
|
|
{'clave': 'entregas', 'etiqueta': 'Entregas',
|
|
|
|
|
'motivo': 'El sistema no registra entregas de productos, así que no hay dato.'},
|
|
|
|
|
{'clave': 'whatsapp_resultados', 'etiqueta': 'Resultados de WhatsApp',
|
|
|
|
|
'motivo': 'No hay una métrica de resultados de WhatsApp disponible en este tablero.'},
|
|
|
|
|
]
|
|
|
|
|
ms = int((datetime.now() - t0).total_seconds() * 1000)
|
|
|
|
|
respuesta = {
|
|
|
|
|
'status': 'success',
|
|
|
|
|
'start': start,
|
|
|
|
|
'end': end,
|
|
|
|
|
'sucursal': sucursal,
|
|
|
|
|
'comparativo': comparativo,
|
|
|
|
|
'resumen': resumen,
|
|
|
|
|
'clientes': clientes,
|
|
|
|
|
'ocupacion': ocupacion,
|
|
|
|
|
'ingresos': ingresos,
|
|
|
|
|
'metas': metas,
|
|
|
|
|
'no_disponible': no_disponible,
|
|
|
|
|
'ms': ms,
|
|
|
|
|
}
|
|
|
|
|
if kw.get('debug') in ('1', 'true'):
|
|
|
|
|
respuesta['_ms_secciones'] = t_sec
|
|
|
|
|
return json_response(respuesta)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# Sucursales (configurables)
|
|
|
|
|
# ============================================================
|
|
|
|
|
|