Oleada UX 5: Servicios (duplicar, favoritos y orden para POS, mas vendidos) y Medicos (semana tipo con validacion en agenda, meta mensual con progreso)

This commit is contained in:
2026-09-17 21:28:43 +00:00
parent 9ee3343e22
commit cd945df6a5
10 changed files with 885 additions and 41 deletions

View File

@@ -265,10 +265,28 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
@api.constrains('date', 'time', 'doctor_id')
def _check_horario_medico(self):
"""Rechaza citas fuera del horario semanal del médico.
Sólo aplica a médicos CON horario configurado y sólo al crear/editar la
fecha, la hora o el médico: las citas históricas fuera de horario se
conservan intactas (cambios de estado u otros campos no las revalidan).
"""
for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
continue
dur = (cita.servicio_id.duration_min if cita.servicio_id else 30) or 30
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, dur)
if not ok:
raise ValidationError(_(mensaje))
def get_available_slots(self, date, servicio_id, doctor_id=None):
"""Retorna slots disponibles para una fecha y servicio.
Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada)."""
(el usuario no debe poder elegir una hora bloqueada) y respeta su
horario semanal cuando lo tiene configurado (sin horario configurado
el comportamiento es el de siempre: 8:00 a 19:00)."""
servicio = self.env['skeen.servicio'].browse(servicio_id)
duration = servicio.duration_min if servicio else 30
@@ -281,6 +299,16 @@ class SkeenCita(models.Model):
start_time = 8.0 # 8:00 AM
end_time = 19.0 # 7:00 PM
# Horario del médico (si lo tiene configurado manda sobre el de la clínica)
if doctor_id:
doctor = self.env['hr.employee'].sudo().browse(int(doctor_id))
if doctor.exists() and doctor.horario_ids:
linea = doctor._horario_del_dia(date)
if not linea:
return [] # día no laborable para el médico
start_time = max(start_time, linea.hour_from)
end_time = min(end_time, linea.hour_to)
# Generar slots
slots = []
current = start_time

View File

@@ -1,6 +1,59 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
from odoo import models, fields, api
from odoo.exceptions import ValidationError
DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']
DIAS_ABREV = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
def _hora_str(h):
"""9.5 -> '9:30'"""
h = float(h or 0.0)
return '%d:%02d' % (int(h), int(round((h - int(h)) * 60)))
class HrEmployeeHorario(models.Model):
"""Línea de horario semanal del médico.
Un registro por día que el médico atiende. Un empleado SIN registros se
considera sin horario configurado (la agenda se comporta como siempre).
"""
_name = 'hr.employee.horario'
_description = 'Horario semanal del médico'
_order = 'day_of_week'
employee_id = fields.Many2one('hr.employee', string='Médico', required=True,
ondelete='cascade', index=True)
day_of_week = fields.Selection([
('0', 'Lunes'),
('1', 'Martes'),
('2', 'Miércoles'),
('3', 'Jueves'),
('4', 'Viernes'),
('5', 'Sábado'),
('6', 'Domingo'),
], string='Día', required=True)
hour_from = fields.Float(string='Hora inicio', required=True, default=9.0)
hour_to = fields.Float(string='Hora fin', required=True, default=14.0)
day_label = fields.Char(string='Día (texto)', compute='_compute_day_label')
_sql_constraints = [
('unique_employee_day', 'unique(employee_id, day_of_week)',
'Ya hay un horario registrado para ese día.'),
]
@api.depends('day_of_week')
def _compute_day_label(self):
for rec in self:
rec.day_label = DIAS[int(rec.day_of_week or 0)]
@api.constrains('hour_from', 'hour_to')
def _check_horas(self):
for rec in self:
if rec.hour_to <= rec.hour_from:
raise ValidationError('La hora fin debe ser mayor a la hora inicio.')
class HrEmployee(models.Model):
@@ -18,3 +71,90 @@ class HrEmployee(models.Model):
default='doctor',
help='Tipo de personal de la agenda: Doctor o Cosmetóloga.',
)
monthly_goal = fields.Float(
string='Meta mensual',
default=0.0,
help='Objetivo de venta del mes (m.n.) para el tablero del médico.',
)
horario_ids = fields.One2many(
'hr.employee.horario', 'employee_id', string='Horario semanal',
help='Días y horas que atiende. Si está vacío, la agenda no restringe horarios.',
)
tiene_horario = fields.Boolean(
string='Con horario configurado', compute='_compute_tiene_horario',
)
horario_resumen = fields.Char(
string='Horario', compute='_compute_horario_resumen',
help='Resumen legible (p. ej. "Lun-Vie 9:00-14:00").',
)
@api.depends('horario_ids')
def _compute_tiene_horario(self):
for emp in self:
emp.tiene_horario = bool(emp.horario_ids)
@api.depends('horario_ids.day_of_week', 'horario_ids.hour_from', 'horario_ids.hour_to')
def _compute_horario_resumen(self):
for emp in self:
emp.horario_resumen = emp._horario_resumen()
# ------------------------------------------------------------------
# Consultas de horario
# ------------------------------------------------------------------
def _horario_del_dia(self, fecha):
"""Línea de horario del día de la semana de `fecha` (vacío si no aplica)."""
self.ensure_one()
if not fecha:
return self.env['hr.employee.horario']
return self.horario_ids.filtered(lambda h: int(h.day_of_week) == fecha.weekday())[:1]
def _horario_resumen(self):
"""Resumen legible agrupando días consecutivos con el mismo horario.
Ej.: "Lun-Vie 9:00-14:00" o "Lun, Mié 9:00-14:00; Sáb 9:00-13:00".
"""
self.ensure_one()
if not self.horario_ids:
return ''
por_hora = {}
for h in self.horario_ids:
clave = (_hora_str(h.hour_from), _hora_str(h.hour_to))
por_hora.setdefault(clave, []).append(int(h.day_of_week))
partes = []
for (desde, hasta), dias in por_hora.items():
dias = sorted(dias)
tramos = []
inicio = previo = dias[0]
for d in dias[1:]:
if d == previo + 1:
previo = d
continue
tramos.append((inicio, previo))
inicio = previo = d
tramos.append((inicio, previo))
etiquetas = [
DIAS_ABREV[a] if a == b else '%s-%s' % (DIAS_ABREV[a], DIAS_ABREV[b])
for a, b in tramos
]
partes.append('%s %s-%s' % (', '.join(etiquetas), desde, hasta))
return '; '.join(partes)
def _valida_cita_en_horario(self, fecha, hora_ini, duracion_min):
"""(ok, mensaje) para una cita en `fecha` a las `hora_ini` (float de horas).
Si el médico no tiene horario configurado devuelve (True, '').
"""
self.ensure_one()
if not self.horario_ids or not fecha:
return True, ''
linea = self._horario_del_dia(fecha)
resumen = self._horario_resumen() or 'sin definir'
if not linea:
return False, 'El doctor no atiende el %s (su horario: %s)' % (
DIAS[fecha.weekday()].lower(), resumen)
dur = (duracion_min or 30) / 60.0
if hora_ini < linea.hour_from - 1e-6 or hora_ini + dur > linea.hour_to + 1e-6:
return False, 'El doctor no atiende a esa hora (su horario: %s)' % resumen
return True, ''

View File

@@ -4,3 +4,4 @@ access_skeen_patient_adjunto_user,Acceso a Adjuntos de Paciente SKEEN,model_skee
access_skeen_receta_user,Acceso a Recetas SKEEN,model_skeen_receta,base.group_user,1,1,1,1
access_skeen_diagnostico_user,Acceso a Diagnósticos SKEEN,model_skeen_diagnostico,base.group_user,1,1,1,1
access_skeen_procedimiento_user,Acceso a Procedimientos SKEEN,model_skeen_procedimiento,base.group_user,1,1,1,1
access_hr_employee_horario_user,Acceso a Horario semanal del médico,model_hr_employee_horario,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 access_skeen_receta_user Acceso a Recetas SKEEN model_skeen_receta base.group_user 1 1 1 1
5 access_skeen_diagnostico_user Acceso a Diagnósticos SKEEN model_skeen_diagnostico base.group_user 1 1 1 1
6 access_skeen_procedimiento_user Acceso a Procedimientos SKEEN model_skeen_procedimiento base.group_user 1 1 1 1
7 access_hr_employee_horario_user Acceso a Horario semanal del médico model_hr_employee_horario base.group_user 1 1 1 1

View File

@@ -2206,16 +2206,38 @@ class SkeenFrontendController(http.Controller):
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200)
Servicio = request.env['skeen.servicio'].sudo()
total = Servicio.search_count(domain)
services = Servicio.search(domain, order='sequence asc, name asc',
limit=page_size, offset=(page - 1) * page_size)
# Orden: manual (sequence, default) o por más vendidos de los
# últimos 90 días. "best_sellers" ordena en memoria porque el
# ranking sale de las líneas de venta, no del catálogo.
sort = kw.get('sort') or 'manual'
ventas_90d = {}
if sort == 'best_sellers':
desde = (date.today() - timedelta(days=90)).strftime('%Y-%m-%d')
lineas = request.env['skeen.venta.line'].sudo().read_group(
[('service_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', desde)],
['service_id', 'quantity:sum'], ['service_id'])
ventas_90d = {g['service_id'][0]: (g.get('quantity') or 0.0) for g in lineas}
if sort == 'best_sellers':
todos = Servicio.search(domain, order='sequence asc, name asc')
todos = todos.sorted(
key=lambda s: (-ventas_90d.get(s.id, 0.0), s.sequence or 0, s.name or ''))
total = len(todos)
services = todos[(page - 1) * page_size: page * page_size]
else:
total = Servicio.search_count(domain)
services = Servicio.search(domain, order='sequence asc, name asc',
limit=page_size, offset=(page - 1) * page_size)
return json_response({
'status': 'success',
'services': [self._service_to_dict(s) for s in services],
'services': [self._service_to_dict(s, ventas_90d) for s in services],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'sort': sort,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -2262,6 +2284,47 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def reorder_services(self, **kw):
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
Los servicios de `ids` quedan, en ese mismo orden, ocupando la posición
del primero de ellos (así funcionan las flechas subir/bajar: se envía la
página ya reordenada). Con `to: 'front'` el bloque pasa al inicio del
catálogo (botón "Mostrar primero en POS"). Con la numeración
normalizada el orden manual es estable y el POS lo respeta al listar.
"""
try:
data = _parse_json_body()
raw = data.get('ids') or []
if not isinstance(raw, list) or not raw:
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
try:
ids = [int(i) for i in raw]
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
Servicio = request.env['skeen.servicio'].sudo()
todos = Servicio.search([], order='sequence asc, name asc')
orden = [s.id for s in todos]
mover_set = set(ids)
mover = [i for i in ids if i in set(orden)]
if not mover:
return json_response({'status': 'error', 'message': 'Ningún servicio válido en la lista'}, 404)
resto = [i for i in orden if i not in mover_set]
if (data.get('to') or '').lower() == 'front':
nuevo = mover + resto
else:
primera = orden.index(mover[0])
pos = len([i for i in orden[:primera] if i not in mover_set])
nuevo = resto[:pos] + mover + resto[pos:]
for idx, sid in enumerate(nuevo):
Servicio.browse(sid).write({'sequence': (idx + 1) * 10})
return json_response({'status': 'success', 'total': len(nuevo), 'ids': nuevo[:len(ids)]})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('admin')
def update_service(self, service_id, **kw):
@@ -2270,9 +2333,14 @@ class SkeenFrontendController(http.Controller):
if not service.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active']
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence']
vals = {k: data[k] for k in allowed if k in data}
Servicio = request.env['skeen.servicio'].sudo()
if 'sequence' in vals:
try:
vals['sequence'] = int(vals['sequence'])
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400)
if vals.get('name'):
dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1)
if dup:
@@ -2286,7 +2354,7 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _service_to_dict(self, s):
def _service_to_dict(self, s, ventas_90d=None):
return {
'id': s.id,
'code': s.code,
@@ -2301,6 +2369,8 @@ class SkeenFrontendController(http.Controller):
'color': s.color,
'service_group': s.service_group or '',
'is_favorite': s.is_favorite,
'sequence': s.sequence or 0,
'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None,
'genera_monedero': s.genera_monedero,
}
@@ -2339,6 +2409,15 @@ class SkeenFrontendController(http.Controller):
# ---- Ventas de los últimos 30 días por médico ----
since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
# ---- Ventas del mes en curso (para la meta mensual) ----
mes_ini = date.today().replace(day=1).strftime('%Y-%m-%d')
mes_total = {}
for g in request.env['skeen.venta.line'].sudo().read_group(
[('prescribed_by_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', mes_ini)],
['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']):
mes_total[g['prescribed_by_id'][0]] = round(g['subtotal'] or 0.0, 2)
# Recomendaciones: pacientes captados en 30d asignados al médico
# con "recomendado por" capturado (misma métrica visible del legacy)
recomendaciones = {}
@@ -2391,6 +2470,15 @@ class SkeenFrontendController(http.Controller):
'work_email': d.work_email or '',
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
'staff_type': d.staff_type or 'doctor',
'monthly_goal': 0.0 if hide_money else (d.monthly_goal or 0.0),
'month_total': 0.0 if hide_money else mes_total.get(d.id, 0.0),
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in d.horario_ids],
'tiene_horario': bool(d.horario_ids),
'horario_resumen': d._horario_resumen(),
'patient_count': counts.get(d.id, 0),
'recomendaciones_30d': recomendaciones.get(d.id, 0),
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
@@ -2413,6 +2501,14 @@ class SkeenFrontendController(http.Controller):
vals = {}
if 'commission_pct' in data:
vals['commission_pct'] = float(data.get('commission_pct') or 0)
if 'monthly_goal' in data:
try:
meta = float(data.get('monthly_goal') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'La meta mensual debe ser numérica'}, 400)
if meta < 0:
return json_response({'status': 'error', 'message': 'La meta mensual no puede ser negativa'}, 400)
vals['monthly_goal'] = meta
if 'staff_type' in data:
if data['staff_type'] not in ('doctor', 'cosmetologa'):
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
@@ -2425,12 +2521,87 @@ class SkeenFrontendController(http.Controller):
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
'commission_pct': doctor.commission_pct or 0.0,
'monthly_goal': doctor.monthly_goal or 0.0,
'staff_type': doctor.staff_type or 'doctor',
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_doctor_horario(self, doctor_id, **kw):
"""Horario semanal del médico (líneas por día que atiende)."""
try:
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
if not doctor.exists():
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
return json_response({
'status': 'success',
'doctor_id': doctor.id,
'tiene_horario': bool(doctor.horario_ids),
'horario_resumen': doctor._horario_resumen(),
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in doctor.horario_ids],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_doctor_horario(self, doctor_id, **kw):
"""Reemplaza el horario semanal del médico.
Body: {"horario": [{"day_of_week": "0", "hour_from": 9, "hour_to": 14}, ...]}
Una lista vacía deja al médico SIN horario configurado (la agenda no
restringe). No toca las citas ya agendadas.
"""
try:
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
if not doctor.exists():
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
data = _parse_json_body()
lineas = data.get('horario')
if not isinstance(lineas, list):
return json_response({'status': 'error', 'message': 'Falta la lista de horario'}, 400)
vals = []
vistos = set()
for l in lineas:
dia = str(l.get('day_of_week', '')).strip()
if dia not in [str(i) for i in range(7)]:
return json_response({'status': 'error', 'message': f'Día inválido: {dia}'}, 400)
if dia in vistos:
return json_response({'status': 'error', 'message': 'Hay días repetidos en el horario'}, 400)
try:
hf = float(l.get('hour_from'))
ht = float(l.get('hour_to'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not (0 <= hf < ht <= 24):
return json_response({
'status': 'error',
'message': 'La hora fin debe ser mayor a la hora inicio y estar entre 0:00 y 24:00',
}, 400)
vistos.add(dia)
vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht}))
doctor.write({'horario_ids': [(5, 0, 0)] + vals})
return json_response({
'status': 'success',
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in doctor.horario_ids],
'tiene_horario': bool(doctor.horario_ids),
'horario_resumen': doctor._horario_resumen(),
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/commission', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_doctor_commission(self, doctor_id, **kw):
@@ -2640,6 +2811,18 @@ class SkeenFrontendController(http.Controller):
else:
pct, pct_source = global_pct, 'global'
# Meta mensual: progreso del MES EN CURSO (independiente del rango
# seleccionado), para la barra meta vs real de "Mis ventas".
mes_ini = today.replace(day=1)
mes_rows = Lineas.read_group([
('prescribed_by_id', '=', emp.id),
('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', mes_ini.strftime('%Y-%m-%d')),
('venta_id.date', '<=', today.strftime('%Y-%m-%d')),
], ['subtotal:sum'], [])
mes_real = round((mes_rows[0]['subtotal'] or 0.0) if mes_rows else 0.0, 2)
meta = emp.monthly_goal or 0.0
return json_response({
'status': 'success',
'doctor': {'id': emp.id, 'name': emp.name},
@@ -2652,6 +2835,13 @@ class SkeenFrontendController(http.Controller):
'total_usd': round(total_mxn / rate, 2) if rate else None,
'total_card': round(sum(card_by_venta.values()), 2),
},
'monthly_goal': {
'goal': meta,
'real': mes_real,
'pct': round(mes_real / meta * 100.0, 1) if meta else None,
'from': mes_ini.strftime('%Y-%m-%d'),
'to': today.strftime('%Y-%m-%d'),
},
'sales': sales,
'daily_breakdown': daily_breakdown,
'commission_pct': pct,