# -*- coding: utf-8 -*-

from odoo import http
from odoo.http import request, Response
import base64
import json
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token


def json_response(data, status=200):
    """Helper para devolver JSON con CORS headers"""
    return Response(
        json.dumps(data, default=str),
        status=status,
        mimetype='application/json',
        headers={
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        }
    )


def _parse_json_body():
    """Lee y parsea el body JSON de la petición"""
    try:
        body = request.httprequest.get_data(as_text=True)
        return json.loads(body) if body else {}
    except Exception:
        return {}


class SkeenFrontendController(http.Controller):
    """Controladores API públicos para el Frontend React de SKEEN"""

    # ============================================================
    # Health
    # ============================================================
    @http.route('/skeen/frontend/v1/health', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    def health_check(self, **kw):
        return json_response({'status': 'ok', 'service': 'skeen-frontend-api', 'version': '1.1.0'})

    # ============================================================
    # Autenticación (frontend React)
    # ============================================================
    @http.route('/skeen/frontend/v1/auth/login', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    def auth_login(self, **kw):
        try:
            if request.httprequest.method == 'OPTIONS':
                return json_response({}, 200)
            data = _parse_json_body()
            login = (data.get('login') or '').strip()
            password = data.get('password') or ''
            if not login or not password:
                return json_response({'status': 'error', 'message': 'Usuario y contraseña son obligatorios'}, 400)
            User = request.env['skeen.frontend.user'].sudo()
            user = User.search([('login', '=', login), ('active', '=', True)], limit=1)
            if not user or not user.check_password(password):
                return json_response({'status': 'error', 'message': 'Credenciales inválidas'}, 401)
            user.write({'last_login': fields.Datetime.now()})
            token = sign_token(request.env, user)
            return json_response({'status': 'success', 'token': token, 'user': user.to_public_dict()})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/auth/me', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def auth_me(self, **kw):
        try:
            return json_response({'status': 'success', 'user': request.frontend_user.to_public_dict()})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/auth/logout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def auth_logout(self, **kw):
        # Token stateless: el frontend simplemente descarta el token.
        return json_response({'status': 'success'})

    @http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('admin')
    def auth_users_list(self, **kw):
        try:
            users = request.env['skeen.frontend.user'].sudo().search([], order='name, login')
            return json_response({'status': 'success', 'users': [u.to_public_dict() | {'active': u.active} for u in users]})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('admin')
    def auth_users_create(self, **kw):
        try:
            data = _parse_json_body()
            login = (data.get('login') or '').strip()
            name = (data.get('name') or '').strip()
            password = data.get('password') or ''
            role = data.get('role') or 'recepcion'
            if not login or not name:
                return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
            if len(password) < 8:
                return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
            if role not in ('admin', 'recepcion', 'medico', 'lectura'):
                return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
            User = request.env['skeen.frontend.user'].sudo()
            if User.search([('login', '=', login)], limit=1):
                return json_response({'status': 'error', 'message': 'El usuario ya existe'}, 409)
            user = User.create({'login': login, 'name': name, 'role': role, 'password_hash': 'pending'})
            user.set_password(password)
            return json_response({'status': 'success', 'user': user.to_public_dict()}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
    @require_role('admin')
    def auth_users_update(self, user_id, **kw):
        try:
            user = request.env['skeen.frontend.user'].sudo().browse(user_id)
            if not user.exists():
                return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
            data = _parse_json_body()
            vals = {}
            if 'name' in data:
                vals['name'] = data['name']
            if 'role' in data:
                if data['role'] not in ('admin', 'recepcion', 'medico', 'lectura'):
                    return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
                vals['role'] = data['role']
            if 'active' in data:
                vals['active'] = bool(data['active'])
            if 'must_change_password' in data:
                vals['must_change_password'] = bool(data['must_change_password'])
            if 'allowed_menus' in data:
                menus = data.get('allowed_menus')
                if menus is not None and not isinstance(menus, list):
                    return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
                vals['allowed_menus'] = json.dumps(menus) if menus else False
            if vals:
                user.write(vals)
            if data.get('password'):
                if len(data['password']) < 8:
                    return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
                user.set_password(data['password'])
            return json_response({'status': 'success', 'user': user.to_public_dict() | {'active': user.active}})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('admin')
    def auth_users_delete(self, user_id, **kw):
        try:
            user = request.env['skeen.frontend.user'].sudo().browse(user_id)
            if not user.exists():
                return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
            if request.frontend_user and request.frontend_user.id == user.id:
                return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400)
            user.unlink()
            return json_response({'status': 'success', 'message': 'Usuario eliminado'})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Dashboard
    # ============================================================
    @http.route('/skeen/frontend/v1/dashboard', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def dashboard_stats(self, **kw):
        """Estadísticas para el dashboard"""
        try:
            today = date.today()
            first_day = today.replace(day=1)
            Citas = request.env['skeen.cita'].sudo()
            Pacientes = request.env['res.partner'].sudo()
            Monederos = request.env['skeen.monedero'].sudo()
            Ventas = request.env['skeen.venta'].sudo()

            citas_hoy = Citas.search_count([('date', '=', today)])
            citas_mes = Citas.search_count([('date', '>=', first_day), ('date', '<=', today)])
            pacientes_nuevos_mes = Pacientes.search_count([
                ('is_patient', '=', True),
                ('create_date', '>=', first_day.strftime('%Y-%m-%d 00:00:00')),
            ])
            total_pacientes = Pacientes.search_count([('is_patient', '=', True)])
            ingresos_mes = sum(v.total for v in Ventas.search([
                ('date', '>=', first_day),
                ('date', '<=', today),
                ('state', 'in', ('paid', 'partial')),
            ]))
            pendientes_pago = Ventas.search_count([('state', 'in', ('confirmed', 'partial')), ('amount_due', '>', 0)])
            puntos_totales = sum(m.points for m in Monederos.search([]))
            ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))])

            return json_response({
                'status': 'success',
                'stats': {
                    'appointments_today': citas_hoy,
                    'appointments_month': citas_mes,
                    'new_patients_month': pacientes_nuevos_mes,
                    'total_patients': total_pacientes,
                    'revenue_month': ingresos_mes,
                    'pending_payments': pendientes_pago,
                    'total_wallet_points': puntos_totales,
                    'sales_today': ventas_hoy,
                }
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/weekly_chart', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def weekly_chart(self, **kw):
        """Datos para gráfico semanal"""
        try:
            today = date.today()
            start = today - timedelta(days=today.weekday())
            data = []
            Citas = request.env['skeen.cita'].sudo()
            Ventas = request.env['skeen.venta'].sudo()
            for i in range(7):
                day = start + timedelta(days=i)
                day_citas = Citas.search([('date', '=', day)])
                day_ventas = Ventas.search([('date', '=', day), ('state', 'in', ('paid', 'partial'))])
                data.append({
                    'name': day.strftime('%a'),
                    'appointments': len(day_citas),
                    'revenue': sum(v.total for v in day_ventas),
                    'patients': len(set(c.partner_id.id for c in day_citas)),
                })
            return json_response({'status': 'success', 'data': data})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Citas
    # ============================================================
    @http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_appointments(self, **kw):
        """Listar citas (opcionalmente filtradas por fecha, estado, paciente)"""
        try:
            domain = []
            if kw.get('date'):
                domain.append(('date', '=', kw.get('date')))
            if kw.get('date_from'):
                domain.append(('date', '>=', kw.get('date_from')))
            if kw.get('date_to'):
                domain.append(('date', '<=', kw.get('date_to')))
            if kw.get('state'):
                domain.append(('state', '=', kw.get('state')))
            if kw.get('patient_id'):
                domain.append(('partner_id', '=', int(kw.get('patient_id'))))
            if kw.get('doctor_id'):
                domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
            if kw.get('search'):
                s = kw.get('search')
                domain += ['|', '|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s), ('servicio_id.name', 'ilike', s)]
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Citas = request.env['skeen.cita'].sudo()
            total = Citas.search_count(domain)
            offset = (page - 1) * page_size
            citas = Citas.search(domain, order='date desc, time desc', limit=page_size, offset=offset)
            dicts = [self._appointment_to_dict(c) for c in citas]
            # Marcar "primera vez": la fecha de la cita coincide con la primera cita del paciente
            partner_ids = list({c.partner_id.id for c in citas})
            if partner_ids:
                groups = Citas.read_group(
                    [('partner_id', 'in', partner_ids), ('state', '!=', 'cancelled')],
                    ['date:min'], ['partner_id'])
                min_dates = {g['partner_id'][0]: str(g['date'])[:10] for g in groups}
                for c, d in zip(citas, dicts):
                    d['is_first_visit'] = bool(c.date) and min_dates.get(c.partner_id.id) == str(c.date)[:10]
            return json_response({
                'status': 'success',
                'appointments': dicts,
                'bloqueos': self._bloqueos_rango(domain),
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_appointment(self, **kw):
        """Crear una o más citas (soporta paquetes de sesiones)"""
        try:
            data = _parse_json_body()
            servicio = request.env['skeen.servicio'].sudo().browse(int(data.get('servicio_id', 0)))
            sessions = int(data.get('sessions', 1) or 1)
            if sessions < 1:
                sessions = 1
            if servicio.exists() and servicio.package_sessions > 0:
                # Si el servicio tiene paquete y no se especificó sessions, default al paquete
                sessions = sessions if data.get('sessions') else servicio.package_sessions
            base_vals = {
                'partner_id': data.get('partner_id'),
                'servicio_id': data.get('servicio_id'),
                'doctor_id': data.get('doctor_id'),
                'branch': data.get('branch', 'rosarito'),
                'medium': data.get('medium', 'onsite'),
                'notes': data.get('notes', ''),
                'state': data.get('state', 'pending'),
            }
            first_date = data.get('date')
            first_time = data.get('time')
            created = []
            # Para paquetes, generamos citas separadas con fechas consecutivas
            current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
            for i in range(sessions):
                vals = dict(base_vals)
                vals['date'] = current_date.strftime('%Y-%m-%d')
                vals['time'] = first_time if i == 0 else 9.0  # Las siguientes se dejan a las 9:00 AM para reprogramar
                cita = request.env['skeen.cita'].sudo().create(vals)
                created.append(cita)
                current_date += timedelta(days=7 if sessions > 1 else 0)
            return json_response({
                'status': 'success',
                'appointment': self._appointment_to_dict(created[0]),
                'appointments': [self._appointment_to_dict(c) for c in created],
                'sessions': sessions,
            }, 201)
        except Exception as e:
            # Rollback explícito: evita persistir citas cuyas validaciones fallaron
            request.env.cr.rollback()
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_appointment(self, appointment_id, **kw):
        try:
            cita = request.env['skeen.cita'].sudo().browse(appointment_id)
            if not cita.exists():
                return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
            return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_appointment(self, appointment_id, **kw):
        try:
            cita = request.env['skeen.cita'].sudo().browse(appointment_id)
            if not cita.exists():
                return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
            data = _parse_json_body()
            allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
            vals = {k: data[k] for k in allowed if k in data}
            cita.write(vals)
            return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
        except Exception as e:
            # Rollback explícito: evita persistir writes parciales (validación rechazada)
            request.env.cr.rollback()
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('recepcion')
    def delete_appointment(self, appointment_id, **kw):
        try:
            cita = request.env['skeen.cita'].sudo().browse(appointment_id)
            if not cita.exists():
                return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
            cita.unlink()
            return json_response({'status': 'success', 'message': 'Cita eliminada'})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def update_appointment_status(self, appointment_id, **kw):
        try:
            cita = request.env['skeen.cita'].sudo().browse(appointment_id)
            if not cita.exists():
                return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
            data = _parse_json_body()
            action = data.get('action')
            if action == 'confirm':
                cita.action_confirm()
            elif action == 'arrive':
                cita.action_arrive()
            elif action == 'start':
                cita.action_start()
            elif action == 'done':
                cita.action_done()
            elif action == 'cancel':
                cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')})
            elif action == 'no_show':
                cita.action_no_show()
            else:
                return json_response({'status': 'error', 'message': 'Acción no válida'}, 400)
            return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def appointment_package_finished(self, appointment_id, **kw):
        try:
            cita = request.env['skeen.cita'].sudo().browse(appointment_id)
            if not cita.exists():
                return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
            data = _parse_json_body()
            # finished=false revierte la marca (reversible)
            finished = data.get('finished', True)
            if finished:
                cita.action_package_finished()
            else:
                cita.write({'package_finished': False, 'package_finished_date': False})
            return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    def available_slots(self, **kw):
        try:
            date_str = kw.get('date')
            servicio_id = int(kw.get('servicio_id', 0))
            if not date_str or not servicio_id:
                return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
            d = datetime.strptime(date_str, '%Y-%m-%d').date()
            slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id)
            return json_response({'status': 'success', 'slots': slots})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Bloqueos de agenda por médico
    # ============================================================
    def _bloqueos_rango(self, citas_domain):
        """Bloqueos del rango de fechas consultado en list_appointments"""
        date_from = date_to = None
        for cond in citas_domain:
            if isinstance(cond, (list, tuple)) and cond[0] == 'date':
                if cond[1] == '=':
                    date_from = date_to = cond[2]
                elif cond[1] == '>=':
                    date_from = cond[2]
                elif cond[1] == '<=':
                    date_to = cond[2]
        if not date_from and not date_to:
            date_from = date_to = date.today().strftime('%Y-%m-%d')
        domain = []
        if date_from:
            domain.append(('date', '>=', date_from))
        if date_to:
            domain.append(('date', '<=', date_to))
        bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, limit=500)
        return [self._bloqueo_to_dict(b) for b in bloqueos]

    def _bloqueo_to_dict(self, b):
        return {
            'id': b.id,
            'doctor_id': b.doctor_id.id,
            'doctor': b.doctor_id.name,
            'date': b.date.strftime('%Y-%m-%d') if b.date else None,
            'all_day': b.all_day,
            'time_from': b.time_from,
            'time_to': b.time_to,
            'motivo': b.motivo or '',
        }

    @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_bloqueos(self, **kw):
        try:
            domain = []
            if kw.get('date_from'):
                domain.append(('date', '>=', kw['date_from']))
            if kw.get('date_to'):
                domain.append(('date', '<=', kw['date_to']))
            if kw.get('doctor_id'):
                domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
            bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc', limit=500)
            return json_response({
                'status': 'success',
                'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_bloqueo(self, **kw):
        try:
            data = _parse_json_body()
            if not data.get('doctor_id') or not data.get('date'):
                return json_response({'status': 'error', 'message': 'Médico y fecha son obligatorios'}, 400)
            all_day = bool(data.get('all_day'))
            time_from = float(data.get('time_from', 9.0) or 9.0)
            time_to = float(data.get('time_to', 10.0) or 10.0)
            if not all_day and time_to <= time_from:
                return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
            doctor = request.env['hr.employee'].sudo().browse(int(data['doctor_id']))
            if not doctor.exists():
                return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
            bloqueo = request.env['skeen.cita.bloqueo'].sudo().create({
                'doctor_id': doctor.id,
                'date': data['date'],
                'all_day': all_day,
                'time_from': time_from,
                'time_to': time_to,
                'motivo': data.get('motivo', ''),
            })
            return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('recepcion')
    def delete_bloqueo(self, bloqueo_id, **kw):
        try:
            bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
            if not bloqueo.exists():
                return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
            bloqueo.unlink()
            return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _appointment_to_dict(self, cita):
        return {
            'id': cita.id,
            'reference': cita.name,
            'patient_id': cita.partner_id.id,
            'patient': cita.partner_id.name,
            'phone': cita.partner_id.phone or '',
            'service_id': cita.servicio_id.id,
            'service': cita.servicio_id.name,
            'service_category': cita.servicio_id.category,
            'date': cita.date.strftime('%Y-%m-%d') if cita.date else None,
            'time': cita.time_str,
            'state': cita.state,
            'payment_state': cita.payment_state,
            'price': cita.servicio_price,
            'amount_paid': cita.amount_paid,
            'duration': cita.servicio_duration,
            'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
            'doctor': cita.doctor_id.name if cita.doctor_id else None,
            'branch': cita.branch,
            'medium': cita.medium,
            'notes': cita.notes or '',
            'is_first_visit': False,
            'package_finished': cita.package_finished,
            'package_finished_date': cita.package_finished_date.strftime('%Y-%m-%d %H:%M') if cita.package_finished_date else None,
        }

    # ============================================================
    # Visitas clínicas
    # ============================================================
    @http.route('/skeen/frontend/v1/visitas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_visitas(self, **kw):
        """Listar visitas clínicas (filtros: fecha, estado, paciente, médico, origen, búsqueda)"""
        try:
            domain = []
            fecha = kw.get('date') or date.today().strftime('%Y-%m-%d')
            if fecha != 'todas':
                domain += [('date_start', '>=', f'{fecha} 00:00:00'), ('date_start', '<=', f'{fecha} 23:59:59')]
            if kw.get('state'):
                domain.append(('state', '=', kw.get('state')))
            if kw.get('origen') in ('cita', 'consulta'):
                domain.append(('origen', '=', kw.get('origen')))
            if kw.get('patient_id'):
                domain.append(('partner_id', '=', int(kw.get('patient_id'))))
            if kw.get('doctor_id'):
                domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
            if kw.get('search'):
                domain.append(('partner_id.name', 'ilike', kw.get('search')))
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Visitas = request.env['skeen.visita'].sudo()
            total = Visitas.search_count(domain)
            offset = (page - 1) * page_size
            visitas = Visitas.search(domain, order='date_start desc', limit=page_size, offset=offset)
            return json_response({
                'status': 'success',
                'visitas': [self._visita_to_dict(v) for v in visitas],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_visita(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_visita(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            data = _parse_json_body()
            allowed = ['motivo', 'diagnostico', 'tratamiento', 'notas', 'receta_text', 'doctor_id', 'cosmetologa_id', 'date_end']
            vals = {k: data[k] for k in allowed if k in data}
            visita.write(vals)
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>/complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def complete_visita(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            visita.action_complete()
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def cancel_visita(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            visita.action_cancel()
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def create_consulta(self, **kw):
        """Consulta médica directa: visita completada sin cita origen"""
        try:
            data = _parse_json_body()
            partner_id = int(data.get('partner_id', 0) or 0)
            partner = request.env['res.partner'].sudo().browse(partner_id)
            if not partner.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            ahora = fields.Datetime.now()
            visita = request.env['skeen.visita'].sudo().create({
                'partner_id': partner.id,
                'origen': 'consulta',
                'state': 'completada',
                'date_start': ahora,
                'date_end': ahora,
                'motivo': data.get('motivo', ''),
                'diagnostico': data.get('diagnostico', ''),
                'tratamiento': data.get('tratamiento', ''),
                'notas': data.get('notas', ''),
                'doctor_id': data.get('doctor_id') or False,
            })
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def add_visita_insumo(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            if visita.state != 'en_curso':
                return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400)
            data = _parse_json_body()
            qty = float(data.get('qty', 0) or 0)
            if qty <= 0:
                return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
            item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
            if not item.exists():
                return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
            request.env['skeen.visita.insumo'].sudo().create({
                'visita_id': visita.id,
                'item_id': item.id,
                'qty': qty,
                'notes': data.get('notes', ''),
            })
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos/<int:line_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('recepcion')
    def delete_visita_insumo(self, visita_id, line_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            if visita.state != 'en_curso':
                return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400)
            linea = request.env['skeen.visita.insumo'].sudo().browse(line_id)
            if not linea.exists() or linea.visita_id.id != visita.id:
                return json_response({'status': 'error', 'message': 'Insumo no encontrado'}, 404)
            linea.unlink()
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/<int:visita_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def add_visita_adjunto(self, visita_id, **kw):
        try:
            visita = request.env['skeen.visita'].sudo().browse(visita_id)
            if not visita.exists():
                return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
            if visita.state == 'cancelada':
                return json_response({'status': 'error', 'message': 'No se pueden adjuntar archivos a una visita cancelada'}, 400)
            data = _parse_json_body()
            kind = data.get('kind')
            if kind not in ('antes', 'despues', 'documento'):
                return json_response({'status': 'error', 'message': 'Tipo de adjunto no válido'}, 400)
            file_b64 = data.get('file_b64') or ''
            try:
                raw = base64.b64decode(file_b64)
            except Exception:
                return json_response({'status': 'error', 'message': 'Archivo base64 inválido'}, 400)
            if not raw:
                return json_response({'status': 'error', 'message': 'Archivo vacío'}, 400)
            if len(raw) > 10 * 1024 * 1024:
                return json_response({'status': 'error', 'message': 'El archivo excede 10 MB'}, 400)
            request.env['skeen.visita.adjunto'].sudo().create({
                'visita_id': visita.id,
                'kind': kind,
                'name': data.get('name') or 'archivo',
                'file': file_b64,
                'mimetype': data.get('mimetype') or '',
                'notes': data.get('notes', ''),
            })
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('recepcion')
    def delete_visita_adjunto(self, adj_id, **kw):
        try:
            adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
            if not adj.exists():
                return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
            visita = adj.visita_id
            adj.unlink()
            return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>/file', type='http', auth='none', methods=['GET'], csrf=False)
    def get_visita_adjunto_file(self, adj_id, **kw):
        """Sirve el binario del adjunto. Acepta Bearer o ?token= (para <img src>)"""
        try:
            user = current_user(request.env)
            if not user:
                # Sin header Authorization: validar token de query string
                payload = verify_token(request.env, kw.get('token') or '')
                if payload:
                    u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
                    if u.exists() and u.active:
                        user = u
            if not user:
                return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
            adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
            if not adj.exists() or not adj.file:
                return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
            return Response(
                base64.b64decode(adj.file),
                status=200,
                mimetype=adj.mimetype or 'application/octet-stream',
                headers={
                    'Content-Disposition': f'inline; filename="{adj.name or "adjunto"}"',
                    'Access-Control-Allow-Origin': '*',
                },
            )
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _visita_to_dict(self, v):
        return {
            'id': v.id,
            'name': v.name,
            'cita_id': v.cita_id.id if v.cita_id else None,
            'partner_id': v.partner_id.id,
            'patient': v.partner_id.name,
            'phone': v.partner_id.phone or '',
            'doctor_id': v.doctor_id.id if v.doctor_id else None,
            'doctor': v.doctor_id.name if v.doctor_id else None,
            'cosmetologa_id': v.cosmetologa_id.id if v.cosmetologa_id else None,
            'cosmetologa': v.cosmetologa_id.name if v.cosmetologa_id else None,
            'servicio_id': v.servicio_id.id if v.servicio_id else None,
            'servicio': v.servicio_id.name if v.servicio_id else None,
            'motivo': v.motivo or '',
            'diagnostico': v.diagnostico or '',
            'tratamiento': v.tratamiento or '',
            'notas': v.notas or '',
            'receta_text': v.receta_text or '',
            'state': v.state,
            'origen': v.origen or 'cita',
            'date_start': v.date_start.strftime('%Y-%m-%d %H:%M') if v.date_start else None,
            'date_end': v.date_end.strftime('%Y-%m-%d %H:%M') if v.date_end else None,
            'insumos': [{
                'id': l.id,
                'item_id': l.item_id.id,
                'item': l.item_id.name,
                'unit': l.item_id.unit or 'pieza',
                'qty': l.qty,
                'notes': l.notes or '',
            } for l in v.insumo_ids],
            'insumos_descargados': v.insumos_descargados,
            'adjuntos': [{
                'id': a.id,
                'kind': a.kind,
                'name': a.name or '',
                'mimetype': a.mimetype or '',
                'notes': a.notes or '',
                'create_date': a.create_date.strftime('%Y-%m-%d %H:%M') if a.create_date else None,
                'url': f'/skeen/frontend/v1/visitas/adjuntos/{a.id}/file',
            } for a in v.adjunto_ids],
        }

    # ============================================================
    # Pacientes
    # ============================================================
    @http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_patients(self, **kw):
        try:
            domain = [('is_patient', '=', True)]
            if kw.get('search'):
                search = kw.get('search')
                domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)]
            if kw.get('incomplete') == '1':
                domain += [('expediente_completion', '<', 100)]
            if kw.get('vip') == '1':
                domain.append(('is_vip', '=', True))
            if kw.get('doctor_id'):
                domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id'))))
            if kw.get('recent') == '1':
                seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
                domain.append(('last_visit', '>=', seis_meses))
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Pacientes = request.env['res.partner'].sudo()
            total = Pacientes.search_count(domain)
            offset = (page - 1) * page_size
            patients = Pacientes.search(domain, limit=page_size, offset=offset, order='name asc')
            return json_response({
                'status': 'success',
                'patients': [self._patient_to_dict(p) for p in patients],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_patient(self, **kw):
        try:
            data = _parse_json_body()
            vals = {
                'name': data.get('name'),
                'phone': data.get('phone'),
                'email': data.get('email', ''),
                'is_patient': True,
                'birth_date': data.get('birth_date'),
                'gender': data.get('gender'),
                'blood_type': data.get('blood_type'),
                'allergies': data.get('allergies', ''),
                'medical_history': data.get('medical_history', ''),
                'current_medication': data.get('current_medication', ''),
                'source': data.get('source', 'whatsapp'),
                'primary_doctor_id': data.get('primary_doctor_id'),
                'is_vip': data.get('is_vip', False),
                # Datos personales extendidos
                'birthplace': data.get('birthplace', ''),
                'occupation': data.get('occupation', ''),
                'marital_status': data.get('marital_status', ''),
                'emergency_contact': data.get('emergency_contact', ''),
                'emergency_phone': data.get('emergency_phone', ''),
                'home_phone': data.get('home_phone', ''),
                'mobile': data.get('mobile', ''),
                'address_notes': data.get('address_notes', ''),
                'referred_by': data.get('referred_by', ''),
                'patient_comments': data.get('patient_comments', ''),
                'internal_notes': data.get('internal_notes', ''),
                # Historia clínica
                'is_pregnant': data.get('is_pregnant', False),
                'is_breastfeeding': data.get('is_breastfeeding', False),
                'uses_contraceptives': data.get('uses_contraceptives', False),
                'children_count': data.get('children_count', 0),
                'kidney_problems': data.get('kidney_problems', False),
                'back_pain': data.get('back_pain', False),
                'heart_disease': data.get('heart_disease', False),
                'respiratory_problems': data.get('respiratory_problems', False),
                'blood_pressure': data.get('blood_pressure', False),
                'diabetes': data.get('diabetes', False),
                'thyroid': data.get('thyroid', False),
                'colitis': data.get('colitis', False),
                'constipation': data.get('constipation', False),
                'liver_problems': data.get('liver_problems', False),
                'surgeries': data.get('surgeries', False),
                'surgeries_notes': data.get('surgeries_notes', ''),
                'varicose_veins': data.get('varicose_veins', False),
                'migraine': data.get('migraine', False),
                'faints_with_needles': data.get('faints_with_needles', False),
                'medical_notes': data.get('medical_notes', ''),
                'whatsapp': data.get('whatsapp', ''),
            }
            if data.get('photo'):
                vals['image_1920'] = data['photo']
            patient = request.env['res.partner'].sudo().create(vals)
            return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_patient(self, patient_id, **kw):
        try:
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_patient(self, patient_id, **kw):
        try:
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            data = _parse_json_body()
            allowed = ['name', 'phone', 'email', 'birth_date', 'gender', 'blood_type',
                       'allergies', 'medical_history', 'current_medication', 'source',
                       'primary_doctor_id', 'is_vip',
                       # Datos personales extendidos
                       'birthplace', 'occupation', 'marital_status', 'emergency_contact',
                       'emergency_phone', 'home_phone', 'mobile', 'address_notes',
                       'referred_by', 'patient_comments', 'internal_notes',
                       # Historia clínica
                       'is_pregnant', 'is_breastfeeding', 'uses_contraceptives', 'children_count',
                       'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems',
                       'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation',
                       'liver_problems', 'surgeries', 'surgeries_notes', 'varicose_veins',
                       'migraine', 'faints_with_needles', 'medical_notes', 'whatsapp']
            vals = {k: data[k] for k in allowed if k in data}
            if data.get('photo'):
                vals['image_1920'] = data['photo']
            patient.write(vals)
            return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>/photo', type='http', auth='none', methods=['GET'], csrf=False)
    def get_patient_photo(self, patient_id, **kw):
        """Sirve la foto del paciente. Acepta Bearer o ?token= (para <img src>)"""
        try:
            user = current_user(request.env)
            if not user:
                # Sin header Authorization: validar token de query string
                payload = verify_token(request.env, kw.get('token') or '')
                if payload:
                    u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
                    if u.exists() and u.active:
                        user = u
            if not user:
                return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists() or not patient.image_1920:
                return json_response({'status': 'error', 'message': 'Foto no encontrada'}, 404)
            raw = base64.b64decode(patient.image_1920)
            mimetype = 'image/png' if raw[:8] == b'\x89PNG\r\n\x1a\n' else 'image/jpeg'
            return Response(
                raw,
                status=200,
                mimetype=mimetype,
                headers={
                    'Content-Disposition': f'inline; filename="paciente-{patient_id}"',
                    'Access-Control-Allow-Origin': '*',
                },
            )
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>/history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def patient_history(self, patient_id, **kw):
        try:
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
            ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
            visitas = request.env['skeen.visita'].sudo().search([('partner_id', '=', patient_id)], order='date_start desc')
            # Estado de cuenta: monedero, gasto total, adeudo y visitas
            monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', patient_id)], limit=1)
            adeudo_rows = request.env['skeen.venta'].sudo().read_group(
                [('partner_id', '=', patient_id), ('state', '!=', 'cancelled'), ('amount_due', '>', 0)],
                ['amount_due:sum'], [])
            estado_cuenta = {
                'puntos_monedero': monedero.points if monedero else 0,
                'total_gastado': patient.total_spent,
                'adeudo': adeudo_rows[0]['amount_due'] if adeudo_rows else 0.0,
                'visitas_total': patient.total_visits,
            }
            return json_response({
                'status': 'success',
                'appointments': [self._appointment_to_dict(c) for c in citas],
                'sales': [self._sale_to_dict(v) for v in ventas],
                'visitas': [self._visita_to_dict(v) for v in visitas],
                'estado_cuenta': estado_cuenta,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def expedientes_recientes(self, **kw):
        """Pacientes con visitas recientes (últimos 30 días por default)"""
        try:
            domain = [('is_patient', '=', True)]
            recencia = kw.get('recencia') or ''
            if recencia == '6m':
                corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
                domain.append(('last_visit', '>=', corte))
            elif recencia == '6m_plus':
                corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
                domain += ['|', ('last_visit', '=', False), ('last_visit', '<', corte)]
            else:
                date_from = kw.get('date_from') or (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
                if date_from != 'todas':
                    domain.append(('last_visit', '>=', date_from))
                else:
                    domain.append(('last_visit', '!=', False))
            if kw.get('vip') == '1':
                domain.append(('is_vip', '=', True))
            if kw.get('doctor_id'):
                domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id'))))
            if kw.get('search'):
                s = kw.get('search')
                domain += ['|', ('name', 'ilike', s), ('phone', 'ilike', s)]
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Partner = request.env['res.partner'].sudo()
            total = Partner.search_count(domain)
            offset = (page - 1) * page_size
            pacientes = Partner.search(domain,
                order='last_visit asc' if recencia == '6m_plus' else 'last_visit desc',
                limit=page_size, offset=offset)
            # Conteo de adjuntos por paciente y tipo en un solo read_group
            counts = {}
            if pacientes:
                grupos = request.env['skeen.patient.adjunto'].sudo().read_group(
                    [('partner_id', 'in', pacientes.ids)],
                    ['partner_id', 'kind'], ['partner_id', 'kind'], lazy=False)
                for g in grupos:
                    counts.setdefault(g['partner_id'][0], {})[g['kind']] = g['__count']
            items = [{
                'id': p.id,
                'patient_id': p.patient_id,
                'name': p.name,
                'phone': p.phone or '',
                'age': p.age,
                'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None,
                'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None,
                'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
                'internal_notes': p.internal_notes or '',
                'expediente_count': counts.get(p.id, {}).get('expediente', 0),
                'imagenes_count': counts.get(p.id, {}).get('imagen', 0),
                'has_photo': bool(p.image_1920),
                'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo',
            } for p in pacientes]
            return json_response({
                'status': 'success',
                'expedientes': items,
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Adjuntos de paciente (expediente escaneado / galería)
    # ============================================================
    def _patient_adjunto_to_dict(self, a):
        return {
            'id': a.id,
            'kind': a.kind,
            'name': a.name or '',
            'mimetype': a.mimetype or '',
            'notes': a.notes or '',
            'create_date': a.create_date.strftime('%Y-%m-%d %H:%M') if a.create_date else None,
            'url': f'/skeen/frontend/v1/patients/adjuntos/{a.id}/file',
        }

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>/adjuntos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_patient_adjuntos(self, patient_id, **kw):
        try:
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            domain = [('partner_id', '=', patient_id)]
            if kw.get('kind') in ('expediente', 'imagen'):
                domain.append(('kind', '=', kw['kind']))
            adjs = request.env['skeen.patient.adjunto'].sudo().search(domain)
            return json_response({
                'status': 'success',
                'adjuntos': [self._patient_adjunto_to_dict(a) for a in adjs],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/<int:patient_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def add_patient_adjunto(self, patient_id, **kw):
        try:
            patient = request.env['res.partner'].sudo().browse(patient_id)
            if not patient.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            data = _parse_json_body()
            kind = data.get('kind')
            if kind not in ('expediente', 'imagen'):
                return json_response({'status': 'error', 'message': 'Tipo de adjunto no válido'}, 400)
            file_b64 = data.get('file_b64') or ''
            try:
                raw = base64.b64decode(file_b64)
            except Exception:
                return json_response({'status': 'error', 'message': 'Archivo base64 inválido'}, 400)
            if not raw:
                return json_response({'status': 'error', 'message': 'Archivo vacío'}, 400)
            if len(raw) > 10 * 1024 * 1024:
                return json_response({'status': 'error', 'message': 'El archivo excede 10 MB'}, 400)
            adj = request.env['skeen.patient.adjunto'].sudo().create({
                'partner_id': patient.id,
                'kind': kind,
                'name': data.get('name') or 'archivo',
                'file': file_b64,
                'mimetype': data.get('mimetype') or '',
                'notes': data.get('notes', ''),
            })
            return json_response({'status': 'success', 'adjunto': self._patient_adjunto_to_dict(adj)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('recepcion')
    def delete_patient_adjunto(self, adj_id, **kw):
        try:
            adj = request.env['skeen.patient.adjunto'].sudo().browse(adj_id)
            if not adj.exists():
                return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
            adj.unlink()
            return json_response({'status': 'success', 'message': 'Adjunto eliminado'})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/patients/adjuntos/<int:adj_id>/file', type='http', auth='none', methods=['GET'], csrf=False)
    def get_patient_adjunto_file(self, adj_id, **kw):
        """Sirve el binario del adjunto. Acepta Bearer o ?token= (para <img src>)"""
        try:
            user = current_user(request.env)
            if not user:
                # Sin header Authorization: validar token de query string
                payload = verify_token(request.env, kw.get('token') or '')
                if payload:
                    u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
                    if u.exists() and u.active:
                        user = u
            if not user:
                return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
            adj = request.env['skeen.patient.adjunto'].sudo().browse(adj_id)
            if not adj.exists() or not adj.file:
                return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
            return Response(
                base64.b64decode(adj.file),
                status=200,
                mimetype=adj.mimetype or 'application/octet-stream',
                headers={
                    'Content-Disposition': f'inline; filename="{adj.name or "adjunto"}"',
                    'Access-Control-Allow-Origin': '*',
                },
            )
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Recetas (plantillas prehechas)
    # ============================================================
    def _receta_to_dict(self, r):
        return {
            'id': r.id,
            'name': r.name,
            'categoria': r.categoria or '',
            'contenido': r.contenido or '',
        }

    @http.route('/skeen/frontend/v1/recetas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_recetas(self, **kw):
        try:
            domain = [('active', '=', True)]
            if kw.get('search'):
                s = kw.get('search')
                domain += ['|', ('name', 'ilike', s), ('categoria', 'ilike', s)]
            recetas = request.env['skeen.receta'].sudo().search(domain, order='name asc', limit=500)
            return json_response({
                'status': 'success',
                'recetas': [self._receta_to_dict(r) for r in recetas],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/recetas', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_receta(self, **kw):
        try:
            data = _parse_json_body()
            if not data.get('name') or not data.get('contenido'):
                return json_response({'status': 'error', 'message': 'Nombre y contenido son obligatorios'}, 400)
            receta = request.env['skeen.receta'].sudo().create({
                'name': data['name'],
                'categoria': data.get('categoria', ''),
                'contenido': data['contenido'],
            })
            return json_response({'status': 'success', 'receta': self._receta_to_dict(receta)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/recetas/<int:receta_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_receta(self, receta_id, **kw):
        try:
            receta = request.env['skeen.receta'].sudo().browse(receta_id)
            if not receta.exists():
                return json_response({'status': 'error', 'message': 'Receta no encontrada'}, 404)
            data = _parse_json_body()
            allowed = ['name', 'categoria', 'contenido', 'active']
            vals = {k: data[k] for k in allowed if k in data}
            receta.write(vals)
            return json_response({'status': 'success', 'receta': self._receta_to_dict(receta)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/recetas/<int:receta_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('admin')
    def delete_receta(self, receta_id, **kw):
        try:
            receta = request.env['skeen.receta'].sudo().browse(receta_id)
            if not receta.exists():
                return json_response({'status': 'error', 'message': 'Receta no encontrada'}, 404)
            receta.unlink()
            return json_response({'status': 'success', 'message': 'Receta eliminada'})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Catálogos: Diagnósticos y Procedimientos
    # ============================================================
    def _catalogo_to_dict(self, r):
        return {
            'id': r.id,
            'name': r.name,
            'categoria': r.categoria or '',
            'descripcion': r.descripcion or '',
        }

    def _catalogo_list(self, model, kw):
        domain = [('active', '=', True)]
        if kw.get('search'):
            s = kw.get('search')
            domain += ['|', ('name', 'ilike', s), ('categoria', 'ilike', s)]
        rows = request.env[model].sudo().search(domain, order='name asc', limit=500)
        return json_response({'status': 'success', 'items': [self._catalogo_to_dict(r) for r in rows]})

    def _catalogo_create(self, model, data, label):
        if not data.get('name'):
            return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
        row = request.env[model].sudo().create({
            'name': data['name'],
            'categoria': data.get('categoria', ''),
            'descripcion': data.get('descripcion', ''),
        })
        return json_response({'status': 'success', 'item': self._catalogo_to_dict(row)}, 201)

    def _catalogo_update(self, model, row_id, data, label):
        row = request.env[model].sudo().browse(row_id)
        if not row.exists():
            return json_response({'status': 'error', 'message': f'{label} no encontrado'}, 404)
        allowed = ['name', 'categoria', 'descripcion', 'active']
        vals = {k: data[k] for k in allowed if k in data}
        row.write(vals)
        return json_response({'status': 'success', 'item': self._catalogo_to_dict(row)})

    def _catalogo_delete(self, model, row_id, label):
        row = request.env[model].sudo().browse(row_id)
        if not row.exists():
            return json_response({'status': 'error', 'message': f'{label} no encontrado'}, 404)
        row.unlink()
        return json_response({'status': 'success', 'message': f'{label} eliminado'})

    @http.route('/skeen/frontend/v1/diagnosticos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_diagnosticos(self, **kw):
        try:
            return self._catalogo_list('skeen.diagnostico', kw)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/diagnosticos', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_diagnostico(self, **kw):
        try:
            return self._catalogo_create('skeen.diagnostico', _parse_json_body(), 'Diagnóstico')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/diagnosticos/<int:row_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_diagnostico(self, row_id, **kw):
        try:
            return self._catalogo_update('skeen.diagnostico', row_id, _parse_json_body(), 'Diagnóstico')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/diagnosticos/<int:row_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('admin')
    def delete_diagnostico(self, row_id, **kw):
        try:
            return self._catalogo_delete('skeen.diagnostico', row_id, 'Diagnóstico')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/procedimientos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_procedimientos(self, **kw):
        try:
            return self._catalogo_list('skeen.procedimiento', kw)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/procedimientos', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_procedimiento(self, **kw):
        try:
            return self._catalogo_create('skeen.procedimiento', _parse_json_body(), 'Procedimiento')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/procedimientos/<int:row_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def update_procedimiento(self, row_id, **kw):
        try:
            return self._catalogo_update('skeen.procedimiento', row_id, _parse_json_body(), 'Procedimiento')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/procedimientos/<int:row_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
    @require_role('admin')
    def delete_procedimiento(self, row_id, **kw):
        try:
            return self._catalogo_delete('skeen.procedimiento', row_id, 'Procedimiento')
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Búsqueda global (barra del header)
    # ============================================================
    @http.route('/skeen/frontend/v1/search', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def global_search(self, **kw):
        try:
            q = (kw.get('q') or '').strip()
            if len(q) < 2:
                return json_response({'status': 'success', 'patients': [], 'appointments': [], 'services': []})
            Partner = request.env['res.partner'].sudo()
            patients = Partner.search(
                [('is_patient', '=', True), '|', '|',
                 ('name', 'ilike', q), ('phone', 'ilike', q), ('email', 'ilike', q)],
                limit=5, order='name asc')
            Citas = request.env['skeen.cita'].sudo()
            citas = Citas.search(
                [('date', '>=', date.today().strftime('%Y-%m-%d')),
                 ('state', 'not in', ('cancelled', 'no_show')), '|',
                 ('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)],
                limit=5, order='date asc, time asc')
            Servicios = request.env['skeen.servicio'].sudo()
            servicios = Servicios.search(
                [('active', '=', True), ('price', '>', 0), ('name', 'ilike', q)],
                limit=5, order='name asc')
            return json_response({
                'status': 'success',
                'patients': [{
                    'id': p.id, 'name': p.name, 'phone': p.phone or '',
                } for p in patients],
                'appointments': [{
                    'id': c.id, 'patient': c.partner_id.name, 'service': c.servicio_id.name,
                    'date': c.date.strftime('%Y-%m-%d') if c.date else None, 'time': c.time_str,
                } for c in citas],
                'services': [{
                    'id': s.id, 'name': s.name, 'price': s.price,
                } for s in servicios],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _patient_to_dict(self, p):
        return {
            'id': p.id,            'patient_id': p.patient_id,
            'name': p.name,
            'phone': p.phone or '',
            'email': p.email or '',
            'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None,
            'age': p.age,
            'gender': p.gender or '',
            'blood_type': p.blood_type or '',
            'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
            'total_visits': p.total_visits,
            'wallet_points': p.wallet_points,
            'total_spent': p.total_spent,
            'source': p.source or '',
            'is_vip': p.is_vip,
            'primary_doctor_id': p.primary_doctor_id.id if p.primary_doctor_id else None,
            'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None,
            'whatsapp': p.whatsapp or '',
            'has_photo': bool(p.image_1920),
            'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo',
            # Datos personales extendidos
            'birthplace': p.birthplace or '',
            'occupation': p.occupation or '',
            'marital_status': p.marital_status or '',
            'emergency_contact': p.emergency_contact or '',
            'emergency_phone': p.emergency_phone or '',
            'home_phone': p.home_phone or '',
            'mobile': p.mobile or '',
            'address_notes': p.address_notes or '',
            'referred_by': p.referred_by or '',
            'patient_comments': p.patient_comments or '',
            'internal_notes': p.internal_notes or '',
            # Historia clínica
            'is_pregnant': p.is_pregnant,
            'is_breastfeeding': p.is_breastfeeding,
            'uses_contraceptives': p.uses_contraceptives,
            'children_count': p.children_count,
            'kidney_problems': p.kidney_problems,
            'back_pain': p.back_pain,
            'heart_disease': p.heart_disease,
            'respiratory_problems': p.respiratory_problems,
            'blood_pressure': p.blood_pressure,
            'diabetes': p.diabetes,
            'thyroid': p.thyroid,
            'colitis': p.colitis,
            'constipation': p.constipation,
            'liver_problems': p.liver_problems,
            'surgeries': p.surgeries,
            'surgeries_notes': p.surgeries_notes or '',
            'varicose_veins': p.varicose_veins,
            'migraine': p.migraine,
            'faints_with_needles': p.faints_with_needles,
            'medical_notes': p.medical_notes or '',
            # Completitud de expediente
            'expediente_completion': p.expediente_completion,
            'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [],
        }

    # ============================================================
    # Servicios
    # ============================================================
    @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    def list_services(self, **kw):
        try:
            domain = [('active', '=', True), ('price', '>', 0), ('duration_min', '>', 0)]
            if kw.get('search'):
                search = kw.get('search')
                domain += ['|', ('name', 'ilike', search), ('code', 'ilike', search)]
            if kw.get('category'):
                domain += [('category', '=', kw.get('category'))]
            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)
            return json_response({
                'status': 'success',
                'services': [self._service_to_dict(s) for s in services],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('admin')
    def create_service(self, **kw):
        try:
            data = _parse_json_body()
            name = (data.get('name') or '').strip()
            if not name:
                return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
            Servicio = request.env['skeen.servicio'].sudo()
            existente = Servicio.search([('name', '=ilike', name)], limit=1)
            if existente:
                return json_response({
                    'status': 'error',
                    'message': f'Ya existe un servicio con ese nombre (ID {existente.id}). Edítalo en vez de duplicarlo.',
                }, 409)
            code = (data.get('code') or '').strip()
            if code:
                dup_code = Servicio.search([('code', '=', code)], limit=1)
                if dup_code:
                    return json_response({
                        'status': 'error',
                        'message': f'El código {code} ya está en uso por "{dup_code.name}".',
                    }, 409)
            vals = {
                'name': name,
                'code': code,
                'category': data.get('category', 'tratamiento'),
                'price': data.get('price', 0.0),
                'duration_min': data.get('duration_min', 30),
                'package_price': data.get('package_price', 0.0),
                'package_sessions': data.get('package_sessions', 0),
                'package_notes': data.get('package_notes', ''),
                'description': data.get('description', ''),
                'color': data.get('color', '#1abc9c'),
                'service_group': data.get('service_group', ''),
                'is_favorite': data.get('is_favorite', False),
            }
            service = Servicio.create(vals)
            return json_response({'status': 'success', 'service': self._service_to_dict(service)}, 201)
        except Exception as e:
            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):
        try:
            service = request.env['skeen.servicio'].sudo().browse(service_id)
            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']
            vals = {k: data[k] for k in allowed if k in data}
            Servicio = request.env['skeen.servicio'].sudo()
            if vals.get('name'):
                dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1)
                if dup:
                    return json_response({'status': 'error', 'message': f'Ya existe otro servicio con ese nombre (ID {dup.id}).'}, 409)
            if vals.get('code'):
                dupc = Servicio.search([('code', '=', vals['code']), ('id', '!=', service_id)], limit=1)
                if dupc:
                    return json_response({'status': 'error', 'message': f'El código {vals["code"]} ya está en uso por "{dupc.name}".'}, 409)
            service.write(vals)
            return json_response({'status': 'success', 'service': self._service_to_dict(service)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _service_to_dict(self, s):
        return {
            'id': s.id,
            'code': s.code,
            'name': s.name,
            'category': s.category,
            'price': s.price,
            'package_price': s.package_price,
            'package_sessions': s.package_sessions,
            'package_notes': s.package_notes or '',
            'duration_min': s.duration_min,
            'description': s.description or '',
            'color': s.color,
            'service_group': s.service_group or '',
            'is_favorite': s.is_favorite,
            'genera_monedero': s.genera_monedero,
        }

    # ============================================================
    # Médicos
    # ============================================================
    @http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    def list_doctors(self, **kw):
        try:
            doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100)
            # Pacientes asignados por médico (primary_doctor_id)
            counts = {}
            for g in request.env['res.partner'].sudo().read_group(
                    [('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)],
                    [], ['primary_doctor_id']):
                counts[g['primary_doctor_id'][0]] = g['primary_doctor_id_count']
            return json_response({
                'status': 'success',
                'doctors': [{
                    'id': d.id,
                    'name': d.name,
                    'job_title': d.job_title or '',
                    'work_phone': d.work_phone or '',
                    'work_email': d.work_email or '',
                    'commission_pct': d.commission_pct or 0.0,
                    'patient_count': counts.get(d.id, 0),
                } for d in doctors],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/doctors/<int:doctor_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('admin')
    def update_doctor(self, doctor_id, **kw):
        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()
            vals = {}
            if 'commission_pct' in data:
                vals['commission_pct'] = float(data.get('commission_pct') or 0)
            if vals:
                doctor.write(vals)
            return json_response({
                'status': 'success',
                'doctor': {
                    '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,
                },
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Productos (plantilla de producto de Odoo)
    # ============================================================
    @http.route('/skeen/frontend/v1/products', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    def list_products(self, **kw):
        try:
            products = request.env['product.template'].sudo().search([('active', '=', True)], limit=100)
            return json_response({
                'status': 'success',
                'products': [{
                    'id': p.id,
                    'name': p.name,
                    'list_price': p.list_price,
                    'default_code': p.default_code or '',
                    'type': p.type,
                } for p in products],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Monederos
    # ============================================================
    @http.route('/skeen/frontend/v1/wallets', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_wallets(self, **kw):
        try:
            domain = []
            if kw.get('phone'):
                domain.append(('partner_id.phone', 'ilike', kw.get('phone')))
            if kw.get('search'):
                s = kw.get('search')
                domain += ['|', ('partner_id.name', 'ilike', s), ('partner_id.phone', 'ilike', s)]
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Monederos = request.env['skeen.monedero'].sudo()
            total = Monederos.search_count(domain)
            offset = (page - 1) * page_size
            wallets = Monederos.search(domain, limit=page_size, offset=offset, order='points desc')
            return json_response({
                'status': 'success',
                'wallets': [self._wallet_to_dict(w) for w in wallets],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/wallets/<string:phone>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_wallet_by_phone(self, phone, **kw):
        try:
            result = request.env['skeen.monedero'].sudo().get_balance(phone)
            return json_response({'status': 'success', 'wallet': result})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/wallets/transaction', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('admin')
    def wallet_transaction(self, **kw):
        try:
            data = _parse_json_body()
            phone = data.get('phone')
            points = int(data.get('points', 0))
            ttype = data.get('type')
            description = data.get('description', '')
            partner = request.env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
            if not partner:
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
            monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', partner.id)], limit=1)
            if not monedero:
                monedero = request.env['skeen.monedero'].sudo().create({'partner_id': partner.id})
            if ttype == 'accrual':
                monedero.add_points(points, description=description)
            elif ttype == 'redemption':
                monedero.redeem_points(points, description=description)
            else:
                return json_response({'status': 'error', 'message': 'Tipo no válido'}, 400)
            return json_response({'status': 'success', 'wallet': self._wallet_to_dict(monedero)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _wallet_to_dict(self, w):
        return {
            'id': w.id,
            'patient_id': w.partner_id.id,
            'patient': w.partner_id.name,
            'phone': w.partner_id.phone or '',
            'points': w.points,
            'equivalent_mxn': w.equivalent_mxn,
        }

    # ============================================================
    # Pagos
    # ============================================================
    @http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_payments(self, **kw):
        try:
            domain = []
            if kw.get('state'):
                domain.append(('state', '=', kw.get('state')))
            payments = request.env['skeen.pago'].sudo().search(domain, order='create_date desc', limit=200)
            return json_response({
                'status': 'success',
                'payments': [self._payment_to_dict(p) for p in payments],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_payment(self, **kw):
        try:
            data = _parse_json_body()
            vals = {
                'partner_id': data.get('partner_id'),
                'cita_id': data.get('cita_id'),
                'amount': data.get('amount', 0.0),
                'payment_method': data.get('payment_method', 'cash'),
            }
            payment = request.env['skeen.pago'].sudo().create(vals)
            return json_response({'status': 'success', 'payment': self._payment_to_dict(payment)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def confirm_payment(self, payment_id, **kw):
        try:
            payment = request.env['skeen.pago'].sudo().browse(payment_id)
            if not payment.exists():
                return json_response({'status': 'error', 'message': 'Pago no encontrado'}, 404)
            payment.action_confirm_payment()
            return json_response({'status': 'success', 'payment': self._payment_to_dict(payment)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _payment_to_dict(self, p):
        return {
            'id': p.id,
            'name': p.name,
            'patient': p.partner_id.name,
            'patient_id': p.partner_id.id,
            'amount': p.amount,
            'payment_method': p.payment_method,
            'state': p.state,
            'payment_url': p.payment_url or '',
            'provider_reference': p.provider_reference or '',
            'create_date': p.create_date.strftime('%Y-%m-%d %H:%M') if p.create_date else None,
        }

    # ============================================================
    # Ventas
    # ============================================================
    @http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_sales(self, **kw):
        try:
            domain = []
            if kw.get('date'):
                domain.append(('date', '=', kw.get('date')))
            if kw.get('date_from'):
                domain.append(('date', '>=', kw.get('date_from')))
            if kw.get('date_to'):
                domain.append(('date', '<=', kw.get('date_to')))
            if kw.get('state'):
                domain.append(('state', '=', kw.get('state')))
            if kw.get('search'):
                s = kw.get('search')
                domain += ['|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s)]
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Ventas = request.env['skeen.venta'].sudo()
            total = Ventas.search_count(domain)
            offset = (page - 1) * page_size
            sales = Ventas.search(domain, order='create_date desc', limit=page_size, offset=offset)
            return json_response({
                'status': 'success',
                'sales': [self._sale_to_dict(v) for v in sales],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_sale(self, **kw):
        try:
            data = _parse_json_body()
            lines = data.get('lines', [])
            vals = {
                'partner_id': data.get('partner_id'),
                'cita_id': data.get('cita_id'),
                'date': data.get('date', fields.Date.today()),
                'discount': data.get('discount', 0.0),
                'tax': data.get('tax', 0.0),
                'notes': data.get('notes', ''),
                'line_ids': [(0, 0, {
                    'service_id': line.get('service_id'),
                    'description': line.get('description', ''),
                    'quantity': line.get('quantity', 1.0),
                    'price_unit': line.get('price_unit', 0.0),
                    'is_prescribed': bool(line.get('is_prescribed', False)),
                    'prescribed_by_id': line.get('prescribed_by_id') or False,
                }) for line in lines],
            }
            sale = request.env['skeen.venta'].sudo().create(vals)
            if data.get('state') == 'confirmed':
                sale.action_confirm()
            return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/sales/<int:sale_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_sale(self, sale_id, **kw):
        try:
            sale = request.env['skeen.venta'].sudo().browse(sale_id)
            if not sale.exists():
                return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
            return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def pay_sale(self, sale_id, **kw):
        try:
            sale = request.env['skeen.venta'].sudo().browse(sale_id)
            if not sale.exists():
                return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
            data = _parse_json_body()
            method = data.get('payment_method', 'cash')
            amount = data.get('amount', sale.amount_due)
            # Crear pago asociado
            payment = request.env['skeen.pago'].sudo().create({
                'partner_id': sale.partner_id.id,
                'cita_id': sale.cita_id.id if sale.cita_id else False,
                'amount': amount,
                'payment_method': method,
                'state': 'completed',
                'payment_date': fields.Datetime.now(),
            })
            sale.write({'amount_paid': sale.amount_paid + amount})
            if sale.amount_paid >= sale.total:
                sale.action_mark_paid()
            else:
                sale.write({'state': 'partial'})
            return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'payment': self._payment_to_dict(payment)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def refund_sale(self, sale_id, **kw):
        try:
            sale = request.env['skeen.venta'].sudo().browse(sale_id)
            if not sale.exists():
                return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
            data = _parse_json_body()
            amount = data.get('amount')  # None o <=0 => devolución total de lo pagado
            reason = data.get('reason', '')
            result = sale.action_refund(amount=amount, reason=reason)
            return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _sale_to_dict(self, v):
        return {
            'id': v.id,
            'name': v.name,
            'patient_id': v.partner_id.id,
            'patient': v.partner_id.name,
            'date': v.date.strftime('%Y-%m-%d') if v.date else None,
            'subtotal': v.subtotal,
            'discount': v.discount,
            'tax': v.tax,
            'total': v.total,
            'amount_paid': v.amount_paid,
            'amount_due': v.amount_due,
            'state': v.state,
            'refunded': v.refunded,
            'refund_amount': v.refund_amount,
            'refund_reason': v.refund_reason or '',
            'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
            'lines': [{
                'id': line.id,
                'service_id': line.service_id.id if line.service_id else None,
                'service': line.service_id.name if line.service_id else '',
                'item_id': line.item_id.id if line.item_id else None,
                'item': line.item_id.name if line.item_id else '',
                'description': line.description or '',
                'quantity': line.quantity,
                'price_unit': line.price_unit,
                'subtotal': line.subtotal,
                'is_prescribed': line.is_prescribed,
                'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None,
                'prescribed_by': line.prescribed_by_id.name if line.prescribed_by_id else None,
            } for line in v.line_ids],
        }

    # ============================================================
    # POS: cobro rápido (venta + pago en una operación)
    # ============================================================
    @http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def pos_checkout(self, **kw):
        """Crea la venta confirmada, aplica canje de puntos y/o registra el pago.

        - pay_with_points: canjea puntos del monedero (1 pt = $1) por el total.
          A diferencia de action_apply_wallet (tope 50%), aquí el canje puede
          cubrir el 100% porque es la forma de pago elegida en caja.
        - action_mark_paid acumula puntos automáticamente (1 pt por $10 del
          total restante); no se duplica acumulación.
        """
        try:
            data = _parse_json_body()
            lines = data.get('lines') or []
            if not lines:
                return json_response({'status': 'error', 'message': 'La venta no tiene líneas'}, 400)
            partner = request.env['res.partner'].sudo().browse(int(data.get('partner_id', 0) or 0))
            if not partner.exists():
                return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)

            discount = float(data.get('discount', 0) or 0)
            line_vals = []
            item_lines = []  # líneas de artículo: para el move 'venta' tras confirmar
            stock_warnings = []
            subtotal = 0.0
            for l in lines:
                qty = float(l.get('quantity', 0) or 0)
                price = float(l.get('price_unit', 0) or 0)
                if qty <= 0:
                    return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
                service_id = int(l.get('service_id', 0) or 0)
                item_id = int(l.get('item_id', 0) or 0)
                if bool(service_id) == bool(item_id):
                    return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
                subtotal += qty * price
                if item_id:
                    item = request.env['skeen.inventario.item'].sudo().browse(item_id)
                    if not item.exists() or not item.active:
                        return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404)
                    line_vals.append((0, 0, {
                        'item_id': item.id,
                        'description': l.get('description') or item.name,
                        'quantity': qty,
                        'price_unit': price,
                        'is_prescribed': bool(l.get('prescribed_by_id')),
                        'prescribed_by_id': l.get('prescribed_by_id') or False,
                    }))
                    item_lines.append((item, qty))
                    if qty > (item.qty or 0):
                        stock_warnings.append(f'{item.name}: existencia {item.qty:g}, se vendieron {qty:g}')
                else:
                    servicio = request.env['skeen.servicio'].sudo().browse(service_id)
                    if not servicio.exists():
                        return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
                    line_vals.append((0, 0, {
                        'service_id': servicio.id,
                        'description': l.get('description') or servicio.name,
                        'quantity': qty,
                        'price_unit': price,
                        'is_prescribed': bool(l.get('prescribed_by_id')),
                        'prescribed_by_id': l.get('prescribed_by_id') or False,
                    }))
            total_est = max(0.0, subtotal - discount)

            # Moneda y tipo de cambio: validar ANTES de crear nada
            currency = data.get('currency', 'MXN')
            rate_used = None
            if currency == 'USD':
                tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
                if not tc:
                    return json_response({'status': 'error', 'message': 'Configura el tipo de cambio USD en Configuración'}, 400)
                rate_used = tc.rate
            elif currency != 'MXN':
                return json_response({'status': 'error', 'message': 'Moneda no soportada (MXN/USD)'}, 400)

            # Validar saldo de puntos ANTES de crear nada
            pay_with_points = bool(data.get('pay_with_points'))
            Monedero = request.env['skeen.monedero'].sudo()
            monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1)
            saldo = monedero.points if monedero else 0
            if pay_with_points and saldo < total_est:
                return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400)

            method = data.get('payment_method', 'cash')
            if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
                return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)

            venta = request.env['skeen.venta'].sudo().create({
                'partner_id': partner.id,
                'discount': discount,
                'line_ids': line_vals,
            })
            venta.action_confirm()

            # Descargo de inventario por cada artículo (el create del move ajusta el stock)
            if item_lines:
                Move = request.env['skeen.inventario.move'].sudo()
                for item, qty in item_lines:
                    Move.create({
                        'item_id': item.id,
                        'type': 'venta',
                        'qty': qty,
                        'reference': venta.name,
                        'notes': 'Venta POS',
                    })

            # Canje de puntos como forma de pago (hasta cubrir el total)
            puntos_usados = 0
            if pay_with_points:
                total = venta.total
                puntos = int(total) if float(total).is_integer() else int(total) + 1
                puntos = min(puntos, saldo)
                monedero.redeem_points(puntos, description=f'Canje puntos venta {venta.name}', reference=venta.name)
                venta.write({'points_redeemed': puntos, 'points_discount': float(puntos)})
                puntos_usados = puntos

            # Pago monetario del restante
            restante = round(venta.amount_due, 2)
            pago = None
            cambio = 0.0
            cambio_usd = 0.0
            recibido_usd = 0.0
            if restante > 0:
                received = float(data.get('amount_received', restante) or restante)
                reference = ''
                if currency == 'USD':
                    # Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
                    recibido_usd = received
                    received = round(recibido_usd * rate_used, 2)
                    reference = f'USD {recibido_usd:.2f} @ TC {rate_used:.2f}'
                pay_amount = restante if received >= restante - 0.001 else received
                cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
                if rate_used:
                    cambio_usd = round(cambio / rate_used, 2)
                pago = request.env['skeen.pago'].sudo().create({
                    'partner_id': partner.id,
                    'amount': pay_amount,
                    'payment_method': method,
                    'state': 'completed',
                    'payment_date': fields.Datetime.now(),
                    'provider_reference': reference,
                })
                venta.write({'amount_paid': venta.amount_paid + pay_amount})
                if venta.amount_paid >= venta.total:
                    venta.action_mark_paid()
                else:
                    venta.write({'state': 'partial'})
            else:
                venta.action_mark_paid()

            partner.invalidate_recordset()
            return json_response({
                'status': 'success',
                'sale': self._sale_to_dict(venta),
                'payment': self._payment_to_dict(pago) if pago else None,
                'cambio': cambio,
                'cambio_usd': cambio_usd,
                'rate_used': rate_used,
                'recibido_usd': recibido_usd,
                'puntos_usados': puntos_usados,
                'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
                'wallet_points': partner.wallet_points,
                'stock_warnings': stock_warnings,
            }, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Cortes de Caja
    # ============================================================
    @http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def list_cash_closings(self, **kw):
        try:
            domain = []
            if kw.get('date'):
                domain.append(('date', '=', kw.get('date')))
            closings = request.env['skeen.corte.caja'].sudo().search(domain, order='date desc', limit=100)
            return json_response({
                'status': 'success',
                'cash_closings': [self._cash_closing_to_dict(c) for c in closings],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def create_cash_closing(self, **kw):
        try:
            data = _parse_json_body()
            vals = {
                'date': data.get('date', fields.Date.today()),
                'opening_cash': data.get('opening_cash', 0.0),
                'notes': data.get('notes', ''),
            }
            closing = request.env['skeen.corte.caja'].sudo().create(vals)
            return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def close_cash_closing(self, closing_id, **kw):
        try:
            closing = request.env['skeen.corte.caja'].sudo().browse(closing_id)
            if not closing.exists():
                return json_response({'status': 'error', 'message': 'Corte no encontrado'}, 404)
            data = _parse_json_body()
            if 'closing_cash' in data:
                closing.write({'closing_cash': data['closing_cash']})
            closing.action_close()
            return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _cash_closing_to_dict(self, c):
        return {
            'id': c.id,
            'name': c.name,
            'date': c.date.strftime('%Y-%m-%d') if c.date else None,
            'user': c.user_id.name,
            'opening_cash': c.opening_cash,
            'total_cash': c.total_cash,
            'total_card': c.total_card,
            'total_transfer': c.total_transfer,
            'total_other': c.total_other,
            'total_sales': c.total_sales,
            'closing_cash': c.closing_cash,
            'difference': c.difference,
            'state': c.state,
        }

    # ============================================================
    # Reportes
    # ============================================================
    @http.route('/skeen/frontend/v1/reports/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_sales(self, **kw):
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            Ventas = request.env['skeen.venta'].sudo()
            ventas = Ventas.search([('date', '>=', start), ('date', '<=', end)])
            # Ingresos agrupados por día (clave nueva; lo demás no cambia)
            by_day_map = {}
            for v in ventas:
                key = v.date.strftime('%Y-%m-%d')
                by_day_map[key] = by_day_map.get(key, 0.0) + v.total
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'total_sales': sum(v.total for v in ventas),
                'total_paid': sum(v.amount_paid for v in ventas),
                'total_due': sum(v.amount_due for v in ventas),
                'count': len(ventas),
                'by_day': [{'date': k, 'total': by_day_map[k]} for k in sorted(by_day_map)],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/adeudos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_adeudos(self, **kw):
        """Cartera por cobrar: adeudo agrupado por paciente (top 200)"""
        try:
            Ventas = request.env['skeen.venta'].sudo()
            rows = Ventas.read_group(
                [('amount_due', '>', 0), ('state', '!=', 'cancelled')],
                ['partner_id', 'amount_due:sum', 'date:max'], ['partner_id'])
            items = []
            for r in rows:
                pid = r['partner_id'][0]
                items.append({
                    'id': pid,
                    'name': r['partner_id'][1],
                    'num_ventas': r.get('__count') or r.get('partner_id_count', 0),
                    'total_adeudo': r['amount_due'],
                    'ultima_venta': str(r['date'])[:10] if r.get('date') else None,
                })
            phones = {}
            if items:
                for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]):
                    phones[p.id] = p.phone or ''
            for i in items:
                i['phone'] = phones.get(i['id'], '')
            items.sort(key=lambda x: -x['total_adeudo'])
            total_cartera = sum(i['total_adeudo'] for i in items)
            return json_response({
                'status': 'success',
                'adeudos': items[:200],
                'total_cartera': total_cartera,
                'total_pacientes': len(items),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/daily', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_daily(self, **kw):
        """Resumen de movimientos del día: citas, ventas, cobros e inventario"""
        try:
            date_str = kw.get('date', date.today().strftime('%Y-%m-%d'))
            # Citas del día por estado
            citas = request.env['skeen.cita'].sudo().search([('date', '=', date_str)])
            citas_by_state = {}
            for c in citas:
                citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
            # Ventas del día
            ventas = request.env['skeen.venta'].sudo().search([('date', '=', date_str)])
            # Cobros del día por método (misma convención que report_cash)
            pagos = request.env['skeen.pago'].sudo().search([
                ('payment_date', '>=', f'{date_str} 00:00:00'),
                ('payment_date', '<=', f'{date_str} 23:59:59'),
                ('state', '=', 'completed'),
            ])
            pagos_by_method = {}
            for p in pagos:
                pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount
            # Movimientos de inventario del día por tipo
            moves = request.env['skeen.inventario.move'].sudo().search([
                ('date', '>=', f'{date_str} 00:00:00'),
                ('date', '<=', f'{date_str} 23:59:59'),
            ])
            moves_by_type = {}
            for mv in moves:
                moves_by_type[mv.type] = moves_by_type.get(mv.type, 0) + 1
            response = {
                'status': 'success',
                'date': date_str,
                'citas': {'total': len(citas), 'by_state': citas_by_state},
                'ventas': {
                    'count': len(ventas),
                    'total': sum(v.total for v in ventas),
                    'total_paid': sum(v.amount_paid for v in ventas),
                    'total_due': sum(v.amount_due for v in ventas),
                },
                'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
                'inventario': {'total': len(moves), 'by_type': moves_by_type},
            }
            # Desglose opcional por médico o por usuario de recepción
            by = kw.get('by')
            if by == 'medico':
                response['por_medico'] = self._daily_por_medico(citas, ventas, pagos)
            elif by == 'recepcion':
                response['por_recepcion'] = self._daily_por_recepcion(ventas, pagos)
            return json_response(response)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    def _daily_por_medico(self, citas, ventas, pagos):
        """Desglose del día por médico: citas (por estado), líneas recetadas y cobros de sus citas"""
        por_medico = {}
        for c in citas:
            doc = c.doctor_id.name if c.doctor_id else 'Sin médico'
            entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
            entry['citas'] += 1
            entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1
        # Ventas del día atribuidas al médico vía líneas recetadas
        if ventas:
            lineas = request.env['skeen.venta.line'].sudo().search([
                ('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False),
            ])
            for l in lineas:
                doc = l.prescribed_by_id.name
                entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
                entry['ventas'] += l.subtotal
        # Cobros del día ligados a cita con médico
        for p in pagos:
            if p.cita_id and p.cita_id.doctor_id:
                doc = p.cita_id.doctor_id.name
                entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
                entry['cobros'] += p.amount
        rows = [{'medico': k, **v} for k, v in por_medico.items()]
        rows.sort(key=lambda r: -(r['ventas'] + r['cobros'] + r['citas']))
        return rows

    def _daily_por_recepcion(self, ventas, pagos):
        """Desglose del día por usuario que registró la venta / cobro (create_uid)"""
        por_usuario = {}
        for v in ventas:
            usr = v.create_uid.name
            entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0})
            entry['ventas_count'] += 1
            entry['ventas_total'] += v.total
        for p in pagos:
            usr = p.create_uid.name
            entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0})
            entry['cobros_total'] += p.amount
        rows = [{'usuario': k, **v} for k, v in por_usuario.items()]
        rows.sort(key=lambda r: -(r['ventas_total'] + r['cobros_total']))
        return rows

    @http.route('/skeen/frontend/v1/reports/comisiones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_comisiones(self, **kw):
        """Comisiones por empleado: líneas recetadas + citas completadas"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            ventas = request.env['skeen.venta'].sudo().search([
                ('state', 'in', ['confirmed', 'paid', 'partial']),
                ('date', '>=', start), ('date', '<=', end),
            ])
            rows = []
            if ventas:
                Lineas = request.env['skeen.venta.line'].sudo()
                line_domain = [('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)]
                grouped = Lineas.read_group(line_domain, ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id'])
                ventas_por_doc = {}
                items_por_doc = {}
                for r in Lineas.search_read(line_domain, ['prescribed_by_id', 'venta_id']):
                    did = r['prescribed_by_id'][0]
                    ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0])
                    items_por_doc[did] = items_por_doc.get(did, 0) + 1
                # Citas completadas por doctor en el rango
                citas_por_doc = {}
                for g in request.env['skeen.cita'].sudo().read_group(
                        [('state', '=', 'done'), ('date', '>=', start), ('date', '<=', end), ('doctor_id', '!=', False)],
                        ['doctor_id'], ['doctor_id']):
                    citas_por_doc[g['doctor_id'][0]] = g.get('__count') or g.get('doctor_id_count', 0)
                Emp = request.env['hr.employee'].sudo()
                for g in grouped:
                    did = g['prescribed_by_id'][0]
                    emp = Emp.browse(did)
                    base = g['subtotal'] or 0.0
                    pct = emp.commission_pct or 0.0
                    rows.append({
                        'doctor_id': emp.id,
                        'doctor': emp.name,
                        'job_title': emp.job_title or '',
                        'items': items_por_doc.get(did, 0),
                        'sales': len(ventas_por_doc.get(did, set())),
                        'citas_done': citas_por_doc.get(did, 0),
                        'total_vendido': base,
                        'commission_pct': pct,
                        'comision': base * pct / 100.0,
                    })
            rows.sort(key=lambda r: -r['comision'])
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'comisiones': rows,
                'total_vendido': sum(r['total_vendido'] for r in rows),
                'total_comision': sum(r['comision'] for r in rows),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/pagos-servicios', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_pagos_servicios(self, **kw):
        """Cobros del periodo y desglose por servicio de las ventas totalmente pagadas.

        skeen.pago no tiene relación directa con venta/servicio, así que el
        desglose por servicio usa las ventas state='paid' del rango (subtotal
        por línea). El KPI de cobrado usa los pagos reales del periodo.
        """
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            pagos = request.env['skeen.pago'].sudo().search([
                ('payment_date', '>=', f'{start} 00:00:00'),
                ('payment_date', '<=', f'{end} 23:59:59'),
                ('state', '=', 'completed'),
            ])
            by_method = {}
            for p in pagos:
                by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount
            # Desglose por servicio: líneas de ventas totalmente pagadas en el rango
            ventas_paid = request.env['skeen.venta'].sudo().search([
                ('state', '=', 'paid'), ('date', '>=', start), ('date', '<=', end),
            ])
            servicios = {}
            if ventas_paid:
                grouped = request.env['skeen.venta.line'].sudo().read_group(
                    [('venta_id', 'in', ventas_paid.ids)],
                    ['service_id', 'subtotal:sum', 'quantity:sum'], ['service_id'])
                for g in grouped:
                    sid = g['service_id'][0] if g['service_id'] else 0
                    servicios[sid] = {
                        'servicio': g['service_id'][1] if g['service_id'] else 'Sin servicio',
                        'cantidad': g['quantity'],
                        'total': g['subtotal'],
                    }
            rows = sorted(servicios.values(), key=lambda x: -x['total'])
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'total_cobrado': sum(p.amount for p in pagos),
                'total_pagos': len(pagos),
                'by_method': by_method,
                'servicios': rows,
                'total_pagado_completo': sum(r['total'] for r in rows),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/pagos-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_pagos_clientes(self, **kw):
        """Cobros agrupados por cliente (top 200)"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            rows = request.env['skeen.pago'].sudo().read_group([
                ('payment_date', '>=', f'{start} 00:00:00'),
                ('payment_date', '<=', f'{end} 23:59:59'),
                ('state', '=', 'completed'),
            ], ['partner_id', 'amount:sum', 'payment_date:max'], ['partner_id'])
            items = [{
                'id': r['partner_id'][0],
                'name': r['partner_id'][1],
                'num_pagos': r.get('__count') or r.get('partner_id_count', 0),
                'total_cobrado': r['amount'],
                'ultimo_pago': str(r['payment_date'])[:16] if r.get('payment_date') else None,
            } for r in rows]
            items.sort(key=lambda x: -x['total_cobrado'])
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'clientes': items[:200],
                'total_cobrado': sum(i['total_cobrado'] for i in items),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/devoluciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_devoluciones(self, **kw):
        """Ventas devueltas en el periodo"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            ventas = request.env['skeen.venta'].sudo().search([
                ('refunded', '=', True), ('date', '>=', start), ('date', '<=', end),
            ], order='date desc')
            rows = [{
                'id': v.id,
                'folio': v.name,
                'paciente': v.partner_id.name,
                'fecha': v.date.strftime('%Y-%m-%d') if v.date else None,
                'total': v.total,
                'refund_amount': v.refund_amount,
                'refund_reason': v.refund_reason or '',
                'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
            } for v in ventas]
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'devoluciones': rows,
                'total_devuelto': sum(r['refund_amount'] for r in rows),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/top-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_top_clientes(self, **kw):
        """Clientes con más compras en el periodo"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            limit = min(max(int(kw.get('limit', 20) or 20), 1), 200)
            rows = request.env['skeen.venta'].sudo().read_group(
                [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)],
                ['partner_id', 'total:sum'], ['partner_id'])
            items = []
            for r in rows:
                n = r.get('__count') or r.get('partner_id_count', 0)
                items.append({
                    'id': r['partner_id'][0],
                    'name': r['partner_id'][1],
                    'num_ventas': n,
                    'total': r['total'],
                    'ticket_promedio': (r['total'] / n) if n else 0.0,
                })
            items.sort(key=lambda x: -x['total'])
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'clientes': items[:limit],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/kpis', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_kpis(self, **kw):
        """KPIs clínicos: no-show, ocupación por médico, conversión de leads, primera vez"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            Citas = request.env['skeen.cita'].sudo()
            citas = Citas.search([('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled')])

            # No-show: tasa global y por médico
            total_citas = len(citas)
            total_no_show = len(citas.filtered(lambda c: c.state == 'no_show'))
            por_medico = {}
            for c in citas:
                doc = c.doctor_id.name if c.doctor_id else 'Sin médico'
                entry = por_medico.setdefault(doc, {'citas': 0, 'no_shows': 0})
                entry['citas'] += 1
                if c.state == 'no_show':
                    entry['no_shows'] += 1
            noshow_medico = [{
                'medico': k,
                'citas': v['citas'],
                'no_shows': v['no_shows'],
                'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0,
            } for k, v in por_medico.items()]
            noshow_medico.sort(key=lambda x: -x['tasa'])

            # Ocupación por médico: horas vendidas vs disponibles (días no domingo × 8h − bloqueos)
            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 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
            ventas_medico = {}
            for c in citas:
                if not c.doctor_id:
                    continue
                dur = (c.servicio_id.duration_min if c.servicio_id else 30) or 30
                ventas_medico[c.doctor_id.name] = ventas_medico.get(c.doctor_id.name, 0.0) + dur / 60.0
            ocupacion = []
            for medico, vendidas in ventas_medico.items():
                disponibles = dias_habiles * 8.0 - bloqueo_horas.get(medico, 0.0)
                disponibles = max(disponibles, 0.0)
                pct = vendidas / disponibles if disponibles > 0 else 0
                ocupacion.append({
                    'medico': medico,
                    'horas_vendidas': round(vendidas, 2),
                    'horas_disponibles': round(disponibles, 2),
                    'horas_libres': round(max(disponibles - vendidas, 0.0), 2),
                    'ocupacion': round(min(pct, 1.0), 4),
                })
            ocupacion.sort(key=lambda x: -x['ocupacion'])
            ocup_promedio = (sum(o['ocupacion'] for o in ocupacion) / len(ocupacion)) if ocupacion else 0

            # Conversión de leads WACRM (created_at en el rango)
            deals = request.env['skeen.wacrm.deal'].sudo().search([
                ('created_at', '>=', f'{start} 00:00:00'),
                ('created_at', '<=', f'{end} 23:59:59'),
            ])
            won = deals.filtered(lambda d: d.status == 'won')
            tiempos = []
            for d in won:
                if d.created_at and d.updated_at and d.updated_at > d.created_at:
                    tiempos.append((d.updated_at - d.created_at).total_seconds() / 86400.0)
            conversion = {
                'total': len(deals),
                'ganados': len(won),
                'tasa': round(len(won) / len(deals), 4) if deals else 0,
                'dias_promedio_ganar': round(sum(tiempos) / len(tiempos), 1) if tiempos else None,
            }

            # Primera vez vs subsecuentes (primera cita del paciente, misma lógica que list_appointments)
            primera = 0
            if citas:
                partner_ids = list({c.partner_id.id for c in citas})
                groups = Citas.read_group(
                    [('partner_id', 'in', partner_ids), ('state', '!=', 'cancelled')],
                    ['date:min'], ['partner_id'])
                min_dates = {g['partner_id'][0]: str(g['date'])[:10] for g in groups}
                primera = sum(1 for c in citas if min_dates.get(c.partner_id.id) == str(c.date)[:10])

            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'no_show': {
                    'tasa': round(total_no_show / total_citas, 4) if total_citas else 0,
                    'total_citas': total_citas,
                    'total_no_shows': total_no_show,
                    'por_medico': noshow_medico,
                },
                'ocupacion': {
                    'promedio': round(ocup_promedio, 4),
                    'dias_habiles': dias_habiles,
                    'por_medico': ocupacion,
                },
                'leads': conversion,
                'primera_vez': {
                    'primera': primera,
                    'subsecuentes': total_citas - primera,
                    'tasa': round(primera / total_citas, 4) if total_citas else 0,
                },
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/notifications', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def notifications(self, **kw):
        """Alertas dinámicas para la campanita del header (calculadas al momento)"""
        try:
            hoy = date.today()
            items = []

            Item = request.env['skeen.inventario.item'].sudo()
            # conteo exacto con la misma regla de /inventario/alertas (bajo mínimo o sin existencia)
            stock_bajo = 0
            for it in Item.search([('active', '=', True)]):
                qty = it.qty or 0.0
                qty_min = it.qty_min or 0.0
                if qty < qty_min or (qty_min == 0 and qty == 0):
                    stock_bajo += 1
            if stock_bajo:
                items.append({
                    'tipo': 'stock_bajo',
                    'titulo': 'Stock bajo',
                    'detalle': f'{stock_bajo} artículos bajo su mínimo',
                    'link': '/inventario?tab=alertas',
                    'severity': 'warning',
                })

            caduc = Item.search_count([
                ('active', '=', True), ('expiry_date', '!=', False),
                ('expiry_date', '<=', (hoy + timedelta(days=30)).strftime('%Y-%m-%d')),
            ])
            if caduc:
                items.append({
                    'tipo': 'caducidades',
                    'titulo': 'Caducidades',
                    'detalle': f'{caduc} artículos vencidos o por vencer en 30 días',
                    'link': '/inventario?tab=alertas',
                    'severity': 'danger',
                })

            pendientes_hoy = request.env['skeen.cita'].sudo().search_count([
                ('date', '=', hoy.strftime('%Y-%m-%d')), ('state', '=', 'pending'),
            ])
            if pendientes_hoy:
                items.append({
                    'tipo': 'citas_pendientes_hoy',
                    'titulo': 'Citas por confirmar',
                    'detalle': f'{pendientes_hoy} citas pendientes hoy',
                    'link': '/agenda',
                    'severity': 'info',
                })

            leads_sin_asignar = request.env['skeen.wacrm.deal'].sudo().search_count([
                ('status', '=', 'open'),
                '|', ('assigned_to', '=', False), ('assigned_to', '=', ''),
            ])
            if leads_sin_asignar:
                items.append({
                    'tipo': 'leads_sin_asignar',
                    'titulo': 'Leads sin asignar',
                    'detalle': f'{leads_sin_asignar} leads abiertos sin agente',
                    'link': '/wacrm/leads',
                    'severity': 'warning',
                })

            convers = request.env['skeen.wacrm.conversation'].sudo().search([('unread_count', '>', 0)])
            sin_leer = sum(c.unread_count for c in convers)
            if sin_leer:
                items.append({
                    'tipo': 'conversaciones_sin_leer',
                    'titulo': 'Mensajes sin leer',
                    'detalle': f'{sin_leer} mensajes no leídos en {len(convers)} conversaciones',
                    'link': '/wacrm/messages',
                    'severity': 'info',
                })

            return json_response({
                'status': 'success',
                'notifications': items,
                'total': len(items),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/horas-agenda', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_horas_agenda(self, **kw):
        """Citas agrupadas por hora del día (8-19) con desglose por estado"""
        try:
            start = kw.get('start', date.today().strftime('%Y-%m-%d'))
            end = kw.get('end', start)
            citas = request.env['skeen.cita'].sudo().search([
                ('date', '>=', start), ('date', '<=', end),
                ('state', 'not in', ('cancelled', 'no_show')),
            ])
            por_hora = {}
            for c in citas:
                hora = int(c.time or 0)
                entry = por_hora.setdefault(hora, {'total': 0, 'by_state': {}})
                entry['total'] += 1
                entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1
            rows = []
            for hora in range(8, 20):
                entry = por_hora.get(hora, {'total': 0, 'by_state': {}})
                rows.append({'hora': f'{hora:02d}:00', 'total': entry['total'], 'by_state': entry['by_state']})
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'horas': rows,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/paquetes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_paquetes(self, **kw):
        """Servicios vendidos como paquete: sesiones, ventas y terminados"""
        try:
            servicios = request.env['skeen.servicio'].sudo().search([
                ('package_sessions', '>', 0), ('active', '=', True),
            ], order='name asc')
            citas_count = {}
            citas_finished = {}
            if servicios:
                Citas = request.env['skeen.cita'].sudo()
                for g in Citas.read_group(
                        [('servicio_id', 'in', servicios.ids), ('state', '!=', 'cancelled')],
                        ['servicio_id'], ['servicio_id']):
                    citas_count[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0)
                for g in Citas.read_group(
                        [('servicio_id', 'in', servicios.ids), ('package_finished', '=', True)],
                        ['servicio_id'], ['servicio_id']):
                    citas_finished[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0)
            rows = [{
                'id': s.id,
                'name': s.name,
                'package_sessions': s.package_sessions,
                'package_price': s.package_price,
                'price': s.price,
                'citas': citas_count.get(s.id, 0),
                'terminados': citas_finished.get(s.id, 0),
            } for s in servicios]
            return json_response({'status': 'success', 'paquetes': rows})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/vendedores', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_vendedores(self, **kw):
        """Ventas del periodo agrupadas por usuario que las registró (create_uid)"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            rows = request.env['skeen.venta'].sudo().read_group(
                [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)],
                ['create_uid', 'total:sum', 'amount_paid:sum'], ['create_uid'])
            items = [{
                'usuario': r['create_uid'][1] if r['create_uid'] else 'Sin usuario',
                'num_ventas': r.get('__count') or r.get('create_uid_count', 0),
                'total': r['total'],
                'cobrado': r['amount_paid'],
            } for r in rows]
            items.sort(key=lambda x: -x['total'])
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'vendedores': items,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/concentrado', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_concentrado(self, **kw):
        """Totales del periodo en una sola respuesta"""
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            citas = request.env['skeen.cita'].sudo().search([('date', '>=', start), ('date', '<=', end)])
            citas_by_state = {}
            for c in citas:
                citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
            ventas = request.env['skeen.venta'].sudo().search([
                ('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end),
            ])
            pagos = request.env['skeen.pago'].sudo().search([
                ('payment_date', '>=', f'{start} 00:00:00'),
                ('payment_date', '<=', f'{end} 23:59:59'),
                ('state', '=', 'completed'),
            ])
            pagos_by_method = {}
            for p in pagos:
                pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount
            devoluciones = request.env['skeen.venta'].sudo().search([
                ('refunded', '=', True), ('date', '>=', start), ('date', '<=', end),
            ])
            pacientes_nuevos = request.env['res.partner'].sudo().search_count([
                ('is_patient', '=', True),
                ('create_date', '>=', f'{start} 00:00:00'),
                ('create_date', '<=', f'{end} 23:59:59'),
            ])
            visitas = request.env['skeen.visita'].sudo().search([
                ('date_start', '>=', f'{start} 00:00:00'),
                ('date_start', '<=', f'{end} 23:59:59'),
            ])
            visitas_by_state = {}
            for v in visitas:
                visitas_by_state[v.state] = visitas_by_state.get(v.state, 0) + 1
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'citas': {'total': len(citas), 'by_state': citas_by_state},
                'ventas': {
                    'count': len(ventas),
                    'total': sum(v.total for v in ventas),
                    'total_paid': sum(v.amount_paid for v in ventas),
                    'total_due': sum(v.amount_due for v in ventas),
                },
                'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
                'devoluciones': {'count': len(devoluciones), 'total': sum(v.refund_amount for v in devoluciones)},
                'pacientes_nuevos': pacientes_nuevos,
                'visitas': {'total': len(visitas), 'by_state': visitas_by_state},
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/recomendaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_recomendaciones(self, **kw):
        """Pacientes agrupados por quién los recomendó"""
        try:
            rows = request.env['res.partner'].sudo().read_group(
                [('is_patient', '=', True), ('referred_by', 'not in', (False, ''))],
                ['referred_by'], ['referred_by'])
            items = [{
                'recomendador': r['referred_by'],
                'pacientes': r.get('__count') or r.get('referred_by_count', 0),
            } for r in rows if r['referred_by']]
            items.sort(key=lambda x: -x['pacientes'])
            return json_response({
                'status': 'success',
                'recomendaciones': items[:200],
                'total': sum(i['pacientes'] for i in items),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/monedero-puntos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_monedero_puntos(self, **kw):
        """Puntos del monedero: emitidos, canjeados y saldo activo"""
        try:
            Tx = request.env['skeen.monedero.transaction'].sudo()
            domain = []
            if kw.get('start'):
                domain.append(('create_date', '>=', f"{kw['start']} 00:00:00"))
            if kw.get('end'):
                domain.append(('create_date', '<=', f"{kw['end']} 23:59:59"))
            by_type = {}
            for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
                by_type[g['type']] = g['points']
            monederos = request.env['skeen.monedero'].sudo().search([])
            return json_response({
                'status': 'success',
                'by_type': by_type,
                'puntos_activos': sum(m.points for m in monederos),
                'cuentas': len(monederos),
                'equivalente_mxn': sum(m.points for m in monederos) * 1.0,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_appointments(self, **kw):
        try:
            start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            Citas = request.env['skeen.cita'].sudo()
            citas = Citas.search([('date', '>=', start), ('date', '<=', end)])
            by_state = {}
            for c in citas:
                by_state[c.state] = by_state.get(c.state, 0) + 1
            return json_response({
                'status': 'success',
                'start': start,
                'end': end,
                'total': len(citas),
                'by_state': by_state,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/cash', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_cash(self, **kw):
        try:
            date_str = kw.get('date', date.today().strftime('%Y-%m-%d'))
            # Rango opcional (start/end); si no viene, comportamiento original por día
            if kw.get('start') and kw.get('end'):
                date_from, date_to = kw['start'], kw['end']
            else:
                date_from = date_to = date_str
            Pagos = request.env['skeen.pago'].sudo()
            pagos = Pagos.search([
                ('payment_date', '>=', f'{date_from} 00:00:00'),
                ('payment_date', '<=', f'{date_to} 23:59:59'),
                ('state', '=', 'completed'),
            ])
            by_method = {}
            for p in pagos:
                by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount
            return json_response({
                'status': 'success',
                'date': date_str,
                'total': sum(p.amount for p in pagos),
                'by_method': by_method,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/inventory', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_inventory(self, **kw):
        """Reporte de stock: KPIs de valor y niveles + detalle por artículo"""
        try:
            items = request.env['skeen.inventario.item'].sudo().search([('active', '=', True)], order='name asc')
            return json_response({
                'status': 'success',
                'summary': {
                    'total_value': sum(i.inventory_value or 0.0 for i in items),
                    'count': len(items),
                    'out': sum(1 for i in items if i.stock_level == 'out'),
                    'critical': sum(1 for i in items if i.stock_level == 'critical'),
                },
                'items': [self._item_to_dict(i) for i in items],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reminders/pending', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def pending_reminders(self, **kw):
        """Devuelve recordatorios pendientes: citas 24h, follow-ups 7d, re-engagement 90d."""
        try:
            today = date.today()
            tomorrow = today + timedelta(days=1)
            seven_days_ago = today - timedelta(days=7)
            ninety_days_ago = today - timedelta(days=90)

            Citas = request.env['skeen.cita'].sudo()
            Pacientes = request.env['res.partner'].sudo()

            # Recordatorios 24h antes
            citas_manana = Citas.search([
                ('date', '=', tomorrow),
                ('state', '=', 'confirmed'),
            ])
            reminders = []
            for c in citas_manana:
                if c.paciente_id and c.paciente_id.phone:
                    reminders.append({
                        'type': 'appointment_reminder',
                        'phone': c.paciente_id.phone,
                        'patient_name': c.paciente_id.name,
                        'appointment_id': c.id,
                        'reference': c.name,
                        'service': c.servicio_id.name if c.servicio_id else '',
                        'date': str(c.date),
                        'time': c.time,
                    })

            # Follow-ups 7 días después
            citas_semana = Citas.search([
                ('date', '=', seven_days_ago),
                ('state', '=', 'done'),
            ])
            followups = []
            for c in citas_semana:
                if c.paciente_id and c.paciente_id.phone:
                    followups.append({
                        'type': 'followup',
                        'phone': c.paciente_id.phone,
                        'patient_name': c.paciente_id.name,
                        'appointment_id': c.id,
                        'service': c.servicio_id.name if c.servicio_id else '',
                        'date': str(c.date),
                    })

            # Re-engagement 90 días (solo pacientes con last_visit real)
            pacientes_inactivos = Pacientes.search([
                ('is_patient', '=', True),
                ('last_visit', '!=', False),
                ('last_visit', '<=', ninety_days_ago),
            ], limit=50)
            reengagements = []
            for p in pacientes_inactivos:
                if p.phone:
                    reengagements.append({
                        'type': 'reengagement',
                        'phone': p.phone,
                        'patient_name': p.name,
                        'last_visit': str(p.last_visit) if p.last_visit else None,
                    })

            return json_response({
                'status': 'success',
                'reminders': reminders,
                'followups': followups,
                'reengagements': reengagements,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/reports/top_services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_top_services(self, **kw):
        try:
            start = kw.get('start', date.today().replace(day=1).strftime('%Y-%m-%d'))
            end = kw.get('end', date.today().strftime('%Y-%m-%d'))
            limit = min(max(int(kw.get('limit', 8) or 8), 1), 50)
            ventas = request.env['skeen.venta'].sudo().search([
                ('date', '>=', start), ('date', '<=', end),
                ('state', 'in', ['confirmed', 'paid', 'partial']),
            ])
            rows = []
            if ventas:
                grouped = request.env['skeen.venta.line'].sudo().read_group(
                    [('venta_id', 'in', ventas.ids)],
                    ['service_id', 'subtotal:sum', 'quantity:sum'],
                    ['service_id'], orderby='subtotal desc', limit=limit,
                )
                for g in grouped:
                    rows.append({
                        'service_id': g['service_id'][0],
                        'service': g['service_id'][1],
                        'qty': g.get('quantity', 0),
                        'revenue': g.get('subtotal', 0.0),
                    })
            return json_response({'status': 'success', 'start': start, 'end': end, 'services': rows})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Tipo de cambio USD/MXN
    # ============================================================
    @http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_exchange_rate(self, **kw):
        try:
            TC = request.env['skeen.tipo.cambio'].sudo()
            current = TC.search([], order='date desc, id desc', limit=1)
            history = TC.search([], order='date desc, id desc', limit=30)
            return json_response({
                'status': 'success',
                'current': {
                    'rate': current.rate if current else 0.0,
                    'date': current.date.strftime('%Y-%m-%d') if current else None,
                    'source': current.source if current else '',
                } if current else None,
                'history': [{
                    'id': r.id,
                    'date': r.date.strftime('%Y-%m-%d'),
                    'rate': r.rate,
                    'source': r.source or '',
                } for r in history],
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('admin')
    def set_exchange_rate(self, **kw):
        try:
            data = _parse_json_body()
            rate = float(data.get('rate') or 0)
            if rate <= 0:
                return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400)
            today = fields.Date.today()
            TC = request.env['skeen.tipo.cambio'].sudo()
            existing = TC.search([('date', '=', today)], limit=1)
            vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')}
            if existing:
                existing.write(vals)
                rec = existing
            else:
                rec = TC.create({'date': today, **vals})
            return json_response({
                'status': 'success',
                'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source},
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Meta de ventas (mensual) vs. avance actual
    # ============================================================
    @http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def get_sales_goal(self, **kw):
        try:
            ICP = request.env['ir.config_parameter'].sudo()
            goal = float(ICP.get_param('skeen.sales_goal_monthly', 0) or 0)
            today = date.today()
            start = today.replace(day=1)
            ventas = request.env['skeen.venta'].sudo().search([
                ('date', '>=', start), ('date', '<=', today),
                ('state', 'in', ['confirmed', 'paid', 'partial']),
            ])
            current = sum(v.total for v in ventas)
            pct = (current / goal * 100.0) if goal > 0 else 0.0
            return json_response({
                'status': 'success',
                'goal': goal,
                'current': current,
                'pct': min(pct, 999.0),
                'start': start.strftime('%Y-%m-%d'),
                'end': today.strftime('%Y-%m-%d'),
                'count': len(ventas),
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('admin')
    def set_sales_goal(self, **kw):
        try:
            data = _parse_json_body()
            goal = float(data.get('goal') or 0)
            if goal < 0:
                return json_response({'status': 'error', 'message': 'goal debe ser >= 0'}, 400)
            request.env['ir.config_parameter'].sudo().set_param('skeen.sales_goal_monthly', goal)
            return json_response({'status': 'success', 'goal': goal})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Reporte de cumpleañeros
    # ============================================================
    @http.route('/skeen/frontend/v1/reports/birthdays', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def report_birthdays(self, **kw):
        try:
            import calendar
            period = kw.get('period', 'month')
            today = date.today()
            if period == 'today':
                start = end = today
            elif period == 'week':
                start = today
                end = today + timedelta(days=7)
            else:  # month (mes actual)
                start = today.replace(day=1)
                end = today.replace(day=calendar.monthrange(today.year, today.month)[1])

            pacientes = request.env['res.partner'].sudo().search([
                ('is_patient', '=', True),
                ('birth_date', '!=', False),
            ])
            matched = []
            for p in pacientes:
                b = p.birth_date
                cands = []
                for yr in (today.year, today.year + 1):
                    try:
                        cands.append(b.replace(year=yr))
                    except ValueError:  # 29-feb en año no bisiesto
                        cands.append(b.replace(year=yr, day=28))
                hit = next((o for o in cands if start <= o <= end), None)
                if hit:
                    matched.append((p, hit))

            matched.sort(key=lambda t: (t[1].month, t[1].day))
            matched = matched[:200]

            # Adeudo por paciente en una sola consulta agrupada
            ids = [p.id for p, _ in matched]
            adeudo_map = {}
            if ids:
                rows = request.env['skeen.venta'].sudo().read_group(
                    [('partner_id', 'in', ids), ('state', 'in', ['confirmed', 'paid', 'partial'])],
                    ['partner_id', 'amount_due:sum'],
                    ['partner_id'],
                )
                for r in rows:
                    adeudo_map[r['partner_id'][0]] = r['amount_due']

            items = [{
                'id': p.id,
                'name': p.name,
                'phone': p.phone or '',
                'birth_date': p.birth_date.strftime('%Y-%m-%d'),
                'occurs_on': occ.strftime('%Y-%m-%d'),
                'turning_age': occ.year - p.birth_date.year,
                'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
                'total_spent': p.total_spent,
                'amount_due': adeudo_map.get(p.id, 0.0),
                'is_vip': p.is_vip,
            } for p, occ in matched]

            return json_response({
                'status': 'success',
                'period': period,
                'start': start.strftime('%Y-%m-%d'),
                'end': end.strftime('%Y-%m-%d'),
                'total': len(items),
                'birthdays': items,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Inventario / Consumibles
    # ============================================================
    def _item_to_dict(self, it):
        return {
            'id': it.id,
            'name': it.name,
            'kind': it.kind,
            'sku': it.sku or '',
            'category': it.category or '',
            'unit': it.unit or 'pieza',
            'qty': it.qty,
            'qty_optimal': it.qty_optimal,
            'qty_min': it.qty_min,
            'cost': it.cost,
            'price': it.price,
            'inventory_value': it.inventory_value,
            'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
            'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
            'active': it.active,
            'genera_monedero': it.genera_monedero,
            'notes': it.notes or '',
            'stock_level': it.stock_level,
        }

    def _move_to_dict(self, m):
        return {
            'id': m.id,
            'item_id': m.item_id.id,
            'type': m.type,
            'qty': m.qty,
            'before_qty': m.before_qty,
            'after_qty': m.after_qty,
            'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None,
            'reference': m.reference or '',
            'notes': m.notes or '',
        }

    @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def inventory_list(self, **kw):
        try:
            Item = request.env['skeen.inventario.item'].sudo()
            domain = [('active', '=', True)]
            if kw.get('kind') in ('producto', 'consumible'):
                domain.append(('kind', '=', kw['kind']))
            if kw.get('level') in ('out', 'critical', 'low', 'optimal'):
                domain.append(('stock_level', '=', kw['level']))
            if kw.get('search'):
                s = kw['search']
                domain += ['|', '|', ('name', 'ilike', s), ('sku', 'ilike', s), ('category', 'ilike', s)]
            items = Item.search(domain, order='name asc', limit=500)
            # Resumen (mismo filtro de kind, sin level/search)
            sum_domain = [('active', '=', True)]
            if kw.get('kind') in ('producto', 'consumible'):
                sum_domain.append(('kind', '=', kw['kind']))
            all_items = Item.search(sum_domain)
            levels = {'out': 0, 'critical': 0, 'low': 0, 'optimal': 0}
            total_value = 0.0
            for a in all_items:
                levels[a.stock_level] = levels.get(a.stock_level, 0) + 1
                total_value += a.inventory_value or 0.0
            return json_response({
                'status': 'success',
                'items': [self._item_to_dict(i) for i in items],
                'summary': {'levels': levels, 'total_value': total_value, 'count': len(all_items)},
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def inventory_create(self, **kw):
        try:
            data = _parse_json_body()
            if not data.get('name'):
                return json_response({'status': 'error', 'message': 'name es obligatorio'}, 400)
            vals = {
                'name': data.get('name'),
                'kind': data.get('kind', 'producto'),
                'sku': data.get('sku', ''),
                'category': data.get('category', ''),
                'unit': data.get('unit', 'pieza'),
                'qty': float(data.get('qty', 0) or 0),
                'qty_optimal': float(data.get('qty_optimal', 0) or 0),
                'qty_min': float(data.get('qty_min', 0) or 0),
                'cost': float(data.get('cost', 0) or 0),
                'expiry_date': data.get('expiry_date') or False,
                'notes': data.get('notes', ''),
            }
            item = request.env['skeen.inventario.item'].sudo().create(vals)
            return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
    @require_role('recepcion')
    def inventory_update(self, item_id, **kw):
        try:
            item = request.env['skeen.inventario.item'].sudo().browse(item_id)
            if not item.exists():
                return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
            data = _parse_json_body()
            allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
                       'cost', 'expiry_date', 'notes', 'active', 'genera_monedero']
            vals = {}
            for k in allowed:
                if k in data:
                    vals[k] = data[k]
            for f in ('qty_optimal', 'qty_min', 'cost'):
                if f in vals:
                    vals[f] = float(vals[f] or 0)
            item.write(vals)
            return json_response({'status': 'success', 'item': self._item_to_dict(item)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def inventory_moves(self, item_id, **kw):
        try:
            moves = request.env['skeen.inventario.move'].sudo().search([('item_id', '=', item_id)], order='date desc', limit=100)
            return json_response({'status': 'success', 'moves': [self._move_to_dict(m) for m in moves]})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
    @require_role('recepcion')
    def inventory_move_create(self, item_id, **kw):
        try:
            item = request.env['skeen.inventario.item'].sudo().browse(item_id)
            if not item.exists():
                return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
            data = _parse_json_body()
            mtype = data.get('type')
            if mtype not in ('compra', 'venta', 'baja', 'ajuste'):
                return json_response({'status': 'error', 'message': 'type inválido'}, 400)
            qty = float(data.get('qty', 0) or 0)
            if qty < 0:
                return json_response({'status': 'error', 'message': 'qty debe ser >= 0'}, 400)
            move = request.env['skeen.inventario.move'].sudo().create({
                'item_id': item_id,
                'type': mtype,
                'qty': qty,
                'reference': data.get('reference', ''),
                'notes': data.get('notes', ''),
            })
            # refrescar item tras el movimiento
            item.invalidate_recordset()
            return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)})
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventario/movimientos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def inventario_movimientos(self, **kw):
        """Movimientos de inventario (filtros: tipo, rango de fechas, paginado)"""
        try:
            domain = []
            if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste'):
                domain.append(('type', '=', kw['type']))
            if kw.get('date_from'):
                domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
            if kw.get('date_to'):
                domain.append(('date', '<=', f"{kw['date_to']} 23:59:59"))
            if kw.get('search'):
                domain.append(('item_id.name', 'ilike', kw['search']))
            page = max(int(kw.get('page', 1) or 1), 1)
            page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
            Moves = request.env['skeen.inventario.move'].sudo()
            total = Moves.search_count(domain)
            offset = (page - 1) * page_size
            moves = Moves.search(domain, order='date desc, id desc', limit=page_size, offset=offset)
            return json_response({
                'status': 'success',
                'movimientos': [{
                    **self._move_to_dict(m),
                    'item': m.item_id.name,
                    'unit': m.item_id.unit or 'pieza',
                } for m in moves],
                'total': total,
                'page': page,
                'page_size': page_size,
                'total_pages': (total + page_size - 1) // page_size,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def inventario_compra(self, **kw):
        """Registrar compra de consumible (sube stock; actualiza costo si viene)"""
        try:
            data = _parse_json_body()
            item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
            if not item.exists():
                return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
            qty = float(data.get('qty', 0) or 0)
            if qty <= 0:
                return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
            if data.get('cost') is not None and data.get('cost') != '':
                cost = float(data.get('cost') or 0)
                if cost < 0:
                    return json_response({'status': 'error', 'message': 'El costo no puede ser negativo'}, 400)
                item.write({'cost': cost})
            move = request.env['skeen.inventario.move'].sudo().create({
                'item_id': item.id,
                'type': 'compra',
                'qty': qty,
                'reference': data.get('reference', ''),
                'notes': data.get('notes', ''),
            })
            item.invalidate_recordset()
            return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventario/alertas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('lectura')
    def inventario_alertas(self, **kw):
        """Caducidades próximas y sugerencia de compra por bajo stock"""
        try:
            Item = request.env['skeen.inventario.item'].sudo()
            today = date.today()
            # Caducidades: todo lo que tiene fecha, ordenado por días restantes
            caducidades = []
            for it in Item.search([('active', '=', True), ('expiry_date', '!=', False)], order='expiry_date asc'):
                dias = (it.expiry_date - today).days
                caducidades.append({
                    'id': it.id,
                    'name': it.name,
                    'category': it.category or '',
                    'qty': it.qty,
                    'unit': it.unit or 'pieza',
                    'expiry_date': it.expiry_date.strftime('%Y-%m-%d'),
                    'dias_restantes': dias,
                    'vencido': dias < 0,
                })
            # Sugerencia de compra: bajo mínimo (o sin mínimo y sin existencia)
            sugerencia = []
            for it in Item.search([('active', '=', True)], order='name asc'):
                qty = it.qty or 0.0
                qty_min = it.qty_min or 0.0
                bajo = (qty < qty_min) or (qty_min == 0 and qty == 0)
                if not bajo:
                    continue
                optimal = it.qty_optimal or 0.0
                if optimal > qty:
                    sugerido = optimal - qty
                else:
                    sugerido = qty_min * 2
                if sugerido <= 0:
                    sugerido = max(1.0, qty_min)
                sugerencia.append({
                    'id': it.id,
                    'name': it.name,
                    'category': it.category or '',
                    'unit': it.unit or 'pieza',
                    'qty': qty,
                    'qty_min': qty_min,
                    'qty_optimal': optimal,
                    'sugerido': sugerido,
                    'costo_estimado': round(sugerido * (it.cost or 0.0), 2),
                })
            return json_response({
                'status': 'success',
                'caducidades': caducidades,
                'sugerencia_compra': sugerencia,
                'sugerencia_total': {
                    'items': len(sugerencia),
                    'costo_total': round(sum(s['costo_estimado'] for s in sugerencia), 2),
                },
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    @http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
    @require_role('recepcion')
    def inventario_baja(self, **kw):
        """Registrar baja/merma de consumible (baja stock)"""
        try:
            data = _parse_json_body()
            item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
            if not item.exists():
                return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
            qty = float(data.get('qty', 0) or 0)
            if qty <= 0:
                return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
            move = request.env['skeen.inventario.move'].sudo().create({
                'item_id': item.id,
                'type': 'baja',
                'qty': qty,
                'reference': data.get('reference', ''),
                'notes': data.get('notes', ''),
            })
            item.invalidate_recordset()
            return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201)
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

    # ============================================================
    # Reporte de comisiones (venta de artículos recetados por médico)
    # ============================================================
    @http.route('/skeen/frontend/v1/reports/commissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
    @require_role('admin')
    def report_commissions(self, **kw):
        try:
            vdomain = [('state', 'in', ['confirmed', 'paid', 'partial'])]
            if kw.get('start'):
                vdomain.append(('date', '>=', kw['start']))
            if kw.get('end'):
                vdomain.append(('date', '<=', kw['end']))
            ventas = request.env['skeen.venta'].sudo().search(vdomain)
            venta_ids = ventas.ids

            rows = []
            total_base = 0.0
            total_commission = 0.0
            if venta_ids:
                line_domain = [('venta_id', 'in', venta_ids), ('is_prescribed', '=', True), ('prescribed_by_id', '!=', False)]
                grouped = request.env['skeen.venta.line'].sudo().read_group(
                    line_domain,
                    ['prescribed_by_id', 'subtotal:sum'],
                    ['prescribed_by_id'],
                )
                # Ventas distintas por médico
                ventas_por_doc = {}
                for r in request.env['skeen.venta.line'].sudo().search_read(line_domain, ['prescribed_by_id', 'venta_id']):
                    did = r['prescribed_by_id'][0]
                    ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0])
                Emp = request.env['hr.employee'].sudo()
                for g in grouped:
                    did = g['prescribed_by_id'][0]
                    emp = Emp.browse(did)
                    base = g['subtotal'] or 0.0
                    pct = emp.commission_pct or 0.0
                    commission = base * pct / 100.0
                    total_base += base
                    total_commission += commission
                    rows.append({
                        'doctor_id': emp.id,
                        'doctor': emp.name,
                        'job_title': emp.job_title or '',
                        'commission_pct': pct,
                        'base': base,
                        'commission': commission,
                        'items': g.get('prescribed_by_id_count', 0),
                        'sales': len(ventas_por_doc.get(did, set())),
                    })
            rows.sort(key=lambda r: r['commission'], reverse=True)
            return json_response({
                'status': 'success',
                'start': kw.get('start'),
                'end': kw.get('end'),
                'total_base': total_base,
                'total_commission': total_commission,
                'commissions': rows,
            })
        except Exception as e:
            return json_response({'status': 'error', 'message': str(e)}, 500)

# Importar fields para fechas
from odoo import fields
