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:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user