# -*- 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/', 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/', 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/', 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/', 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/', 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//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//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/', 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/', 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/', 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//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//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//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//insumos/', 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//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/', 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//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 )""" 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/', 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/', 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//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 )""" 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//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//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//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/', 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//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 )""" 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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//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/', 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//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//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//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/', 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//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//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