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