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

@@ -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,