Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/frontend.py
Consultoría Alcaraz Salazar a718592291 Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
2026-07-20 07:44:23 +00:00

1655 lines
80 KiB
Python

# -*- 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/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
def auth_users_update(self, user_id, **kw):
try:
user = request.env['skeen.frontend.user'].sudo().browse(user_id)
if not user.exists():
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data:
vals['name'] = data['name']
if 'role' in data:
if data['role'] not in ('admin', 'recepcion', 'medico', 'lectura'):
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
vals['role'] = data['role']
if 'active' in data:
vals['active'] = bool(data['active'])
if 'must_change_password' in data:
vals['must_change_password'] = bool(data['must_change_password'])
if 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/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
vals = {k: data[k] for k in allowed if k in data}
cita.write(vals)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
cita.unlink()
return json_response({'status': 'success', 'message': 'Cita eliminada'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def update_appointment_status(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
action = data.get('action')
if action == 'confirm':
cita.action_confirm()
elif action == 'arrive':
cita.action_arrive()
elif action == 'start':
cita.action_start()
elif action == 'done':
cita.action_done()
elif action == 'cancel':
cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')})
elif action == 'no_show':
cita.action_no_show()
else:
return json_response({'status': 'error', 'message': 'Acción no válida'}, 400)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def appointment_package_finished(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
# finished=false revierte la marca (reversible)
finished = data.get('finished', True)
if finished:
cita.action_package_finished()
else:
cita.write({'package_finished': False, 'package_finished_date': False})
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw):
try:
date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0))
if not date_str or not servicio_id:
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
d = datetime.strptime(date_str, '%Y-%m-%d').date()
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id)
return json_response({'status': 'success', 'slots': slots})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
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/<int:patient_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_patient(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_patient(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'phone', 'email', 'birth_date', 'gender', 'blood_type',
'allergies', 'medical_history', 'current_medication', 'source',
'primary_doctor_id', 'is_vip',
# Datos personales extendidos
'birthplace', 'occupation', 'marital_status', 'emergency_contact',
'emergency_phone', 'home_phone', 'mobile', 'address_notes',
'referred_by', 'patient_comments', 'internal_notes',
# Historia clínica
'is_pregnant', 'is_breastfeeding', 'uses_contraceptives', 'children_count',
'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems',
'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation',
'liver_problems', 'surgeries', 'surgeries_notes', 'varicose_veins',
'migraine', 'faints_with_needles', 'medical_notes']
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/<int:patient_id>/history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_history(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
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/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('admin')
def update_service(self, service_id, **kw):
try:
service = request.env['skeen.servicio'].sudo().browse(service_id)
if not service.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', '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/<int:doctor_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('admin')
def update_doctor(self, doctor_id, **kw):
try:
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
if not doctor.exists():
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'commission_pct' in data:
vals['commission_pct'] = float(data.get('commission_pct') or 0)
if vals:
doctor.write(vals)
return json_response({
'status': 'success',
'doctor': {
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
'commission_pct': doctor.commission_pct or 0.0,
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Productos (plantilla de producto de Odoo)
# ============================================================
@http.route('/skeen/frontend/v1/products', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def list_products(self, **kw):
try:
products = request.env['product.template'].sudo().search([('active', '=', True)], limit=100)
return json_response({
'status': 'success',
'products': [{
'id': p.id,
'name': p.name,
'list_price': p.list_price,
'default_code': p.default_code or '',
'type': p.type,
} for p in products],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Monederos
# ============================================================
@http.route('/skeen/frontend/v1/wallets', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_wallets(self, **kw):
try:
domain = []
if kw.get('phone'):
domain.append(('partner_id.phone', 'ilike', kw.get('phone')))
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('partner_id.name', 'ilike', s), ('partner_id.phone', 'ilike', s)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Monederos = request.env['skeen.monedero'].sudo()
total = Monederos.search_count(domain)
offset = (page - 1) * page_size
wallets = Monederos.search(domain, limit=page_size, offset=offset, order='points desc')
return json_response({
'status': 'success',
'wallets': [self._wallet_to_dict(w) for w in wallets],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wallets/<string:phone>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_wallet_by_phone(self, phone, **kw):
try:
result = request.env['skeen.monedero'].sudo().get_balance(phone)
return json_response({'status': 'success', 'wallet': result})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wallets/transaction', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('admin')
def wallet_transaction(self, **kw):
try:
data = _parse_json_body()
phone = data.get('phone')
points = int(data.get('points', 0))
ttype = data.get('type')
description = data.get('description', '')
partner = request.env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
if not partner:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', partner.id)], limit=1)
if not monedero:
monedero = request.env['skeen.monedero'].sudo().create({'partner_id': partner.id})
if ttype == 'accrual':
monedero.add_points(points, description=description)
elif ttype == 'redemption':
monedero.redeem_points(points, description=description)
else:
return json_response({'status': 'error', 'message': 'Tipo no válido'}, 400)
return json_response({'status': 'success', 'wallet': self._wallet_to_dict(monedero)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _wallet_to_dict(self, w):
return {
'id': w.id,
'patient_id': w.partner_id.id,
'patient': w.partner_id.name,
'phone': w.partner_id.phone or '',
'points': w.points,
'equivalent_mxn': w.equivalent_mxn,
}
# ============================================================
# Pagos
# ============================================================
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_payments(self, **kw):
try:
domain = []
if kw.get('state'):
domain.append(('state', '=', kw.get('state')))
payments = request.env['skeen.pago'].sudo().search(domain, order='create_date desc', limit=200)
return json_response({
'status': 'success',
'payments': [self._payment_to_dict(p) for p in payments],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_payment(self, **kw):
try:
data = _parse_json_body()
vals = {
'partner_id': data.get('partner_id'),
'cita_id': data.get('cita_id'),
'amount': data.get('amount', 0.0),
'payment_method': data.get('payment_method', 'cash'),
}
payment = request.env['skeen.pago'].sudo().create(vals)
return json_response({'status': 'success', 'payment': self._payment_to_dict(payment)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def confirm_payment(self, payment_id, **kw):
try:
payment = request.env['skeen.pago'].sudo().browse(payment_id)
if not payment.exists():
return json_response({'status': 'error', 'message': 'Pago no encontrado'}, 404)
payment.action_confirm_payment()
return json_response({'status': 'success', 'payment': self._payment_to_dict(payment)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _payment_to_dict(self, p):
return {
'id': p.id,
'name': p.name,
'patient': p.partner_id.name,
'patient_id': p.partner_id.id,
'amount': p.amount,
'payment_method': p.payment_method,
'state': p.state,
'payment_url': p.payment_url or '',
'provider_reference': p.provider_reference or '',
'create_date': p.create_date.strftime('%Y-%m-%d %H:%M') if p.create_date else None,
}
# ============================================================
# Ventas
# ============================================================
@http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_sales(self, **kw):
try:
domain = []
if kw.get('date'):
domain.append(('date', '=', kw.get('date')))
if kw.get('date_from'):
domain.append(('date', '>=', kw.get('date_from')))
if kw.get('date_to'):
domain.append(('date', '<=', kw.get('date_to')))
if kw.get('state'):
domain.append(('state', '=', kw.get('state')))
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Ventas = request.env['skeen.venta'].sudo()
total = Ventas.search_count(domain)
offset = (page - 1) * page_size
sales = Ventas.search(domain, order='create_date desc', limit=page_size, offset=offset)
return json_response({
'status': 'success',
'sales': [self._sale_to_dict(v) for v in sales],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_sale(self, **kw):
try:
data = _parse_json_body()
lines = data.get('lines', [])
vals = {
'partner_id': data.get('partner_id'),
'cita_id': data.get('cita_id'),
'date': data.get('date', fields.Date.today()),
'discount': data.get('discount', 0.0),
'tax': data.get('tax', 0.0),
'notes': data.get('notes', ''),
'line_ids': [(0, 0, {
'service_id': line.get('service_id'),
'description': line.get('description', ''),
'quantity': line.get('quantity', 1.0),
'price_unit': line.get('price_unit', 0.0),
'is_prescribed': bool(line.get('is_prescribed', False)),
'prescribed_by_id': line.get('prescribed_by_id') or False,
}) for line in lines],
}
sale = request.env['skeen.venta'].sudo().create(vals)
if data.get('state') == 'confirmed':
sale.action_confirm()
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_sale(self, sale_id, **kw):
try:
sale = request.env['skeen.venta'].sudo().browse(sale_id)
if not sale.exists():
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def pay_sale(self, sale_id, **kw):
try:
sale = request.env['skeen.venta'].sudo().browse(sale_id)
if not sale.exists():
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
data = _parse_json_body()
method = data.get('payment_method', 'cash')
amount = data.get('amount', sale.amount_due)
# Crear pago asociado
payment = request.env['skeen.pago'].sudo().create({
'partner_id': sale.partner_id.id,
'cita_id': sale.cita_id.id if sale.cita_id else False,
'amount': amount,
'payment_method': method,
'state': 'completed',
'payment_date': fields.Datetime.now(),
})
sale.write({'amount_paid': sale.amount_paid + amount})
if sale.amount_paid >= sale.total:
sale.action_mark_paid()
else:
sale.write({'state': 'partial'})
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'payment': self._payment_to_dict(payment)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def refund_sale(self, sale_id, **kw):
try:
sale = request.env['skeen.venta'].sudo().browse(sale_id)
if not sale.exists():
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
data = _parse_json_body()
amount = data.get('amount') # None o <=0 => devolución total de lo pagado
reason = data.get('reason', '')
result = sale.action_refund(amount=amount, reason=reason)
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _sale_to_dict(self, v):
return {
'id': v.id,
'name': v.name,
'patient_id': v.partner_id.id,
'patient': v.partner_id.name,
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
'subtotal': v.subtotal,
'discount': v.discount,
'tax': v.tax,
'total': v.total,
'amount_paid': v.amount_paid,
'amount_due': v.amount_due,
'state': v.state,
'refunded': v.refunded,
'refund_amount': v.refund_amount,
'refund_reason': v.refund_reason or '',
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
'lines': [{
'id': line.id,
'service_id': line.service_id.id,
'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/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def close_cash_closing(self, closing_id, **kw):
try:
closing = request.env['skeen.corte.caja'].sudo().browse(closing_id)
if not closing.exists():
return json_response({'status': 'error', 'message': 'Corte no encontrado'}, 404)
data = _parse_json_body()
if 'closing_cash' in data:
closing.write({'closing_cash': data['closing_cash']})
closing.action_close()
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _cash_closing_to_dict(self, c):
return {
'id': c.id,
'name': c.name,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'user': c.user_id.name,
'opening_cash': c.opening_cash,
'total_cash': c.total_cash,
'total_card': c.total_card,
'total_transfer': c.total_transfer,
'total_other': c.total_other,
'total_sales': c.total_sales,
'closing_cash': c.closing_cash,
'difference': c.difference,
'state': c.state,
}
# ============================================================
# Reportes
# ============================================================
@http.route('/skeen/frontend/v1/reports/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_sales(self, **kw):
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
Ventas = request.env['skeen.venta'].sudo()
ventas = Ventas.search([('date', '>=', start), ('date', '<=', end)])
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/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def inventory_update(self, item_id, **kw):
try:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists():
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'expiry_date', 'notes', 'active']
vals = {}
for k in allowed:
if k in data:
vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost'):
if f in vals:
vals[f] = float(vals[f] or 0)
item.write(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def inventory_moves(self, item_id, **kw):
try:
moves = request.env['skeen.inventario.move'].sudo().search([('item_id', '=', item_id)], order='date desc', limit=100)
return json_response({'status': 'success', 'moves': [self._move_to_dict(m) for m in moves]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def inventory_move_create(self, item_id, **kw):
try:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists():
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
mtype = data.get('type')
if mtype not in ('compra', 'venta', 'baja', 'ajuste'):
return json_response({'status': 'error', 'message': 'type inválido'}, 400)
qty = float(data.get('qty', 0) or 0)
if qty < 0:
return json_response({'status': 'error', 'message': 'qty debe ser >= 0'}, 400)
move = request.env['skeen.inventario.move'].sudo().create({
'item_id': item_id,
'type': mtype,
'qty': qty,
'reference': data.get('reference', ''),
'notes': data.get('notes', ''),
})
# refrescar item tras el movimiento
item.invalidate_recordset()
return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# 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