bloque 5: tablero de direccion (pagina /direccion con permiso propio: resumen con comparativos, cohortes de retencion, valor por cliente, clientes en riesgo, ocupacion por medico/sucursal/recurso, ingresos por servicio y sucursal, membresias y paquetes, pasivo de lealtad, metas por sucursal con proyeccion y exportacion) con cifras cruzadas contra los reportes existentes y consultas agrupadas (menos de 2 s)

This commit is contained in:
2026-10-12 03:34:00 +00:00
parent a6d6df4ebd
commit 6139df6327
15 changed files with 2256 additions and 3 deletions

View File

@@ -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)
# ============================================================