# -*- coding: utf-8 -*- from odoo import http from odoo.http import request, Response import json from datetime import date, datetime, timedelta from .auth import sign_token, current_user, require_role 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 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) # ============================================================ # 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) return json_response({ 'status': 'success', 'appointments': [self._appointment_to_dict(c) for c in citas], '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: 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: 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) 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, '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 '', '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, } # ============================================================ # 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)] 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', ''), } 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'] vals = {k: data[k] for k in allowed if k in data} 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//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') 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], }) 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, # 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 '', } # ============================================================ # Servicios # ============================================================ @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_services(self, **kw): try: services = request.env['skeen.servicio'].sudo().search([ ('active', '=', True), ('price', '>', 0), ('duration_min', '>', 0), ], order='sequence asc, name asc', limit=200) return json_response({ 'status': 'success', 'services': [self._service_to_dict(s) for s in services], }) 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() vals = { 'name': data.get('name'), 'code': data.get('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 = request.env['skeen.servicio'].sudo().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', 'active'] vals = {k: data[k] for k in allowed if k in data} 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, } # ============================================================ # 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) 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, } 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, 'service': line.service_id.name, '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], } # ============================================================ # 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)]) 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), }) 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')) Pagos = request.env['skeen.pago'].sudo() pagos = Pagos.search([ ('payment_date', '>=', f'{date_str} 00:00:00'), ('payment_date', '<=', f'{date_str} 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/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, '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, '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'] 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) # ============================================================ # 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