Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/frontend.py
Consultoría Alcaraz Salazar 4c3eb0b11d Médicos: ventas 30d por médico (count, total MXN/USD, pagado tarjeta)
- card_amount en ventas (pagos con terminal física)
- Stats por médico vía líneas recetadas, métrica consistente con comisiones
- 4 columnas nuevas en la página de Médicos
2026-08-14 09:02:22 +00:00

3737 lines
185 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request, Response
import base64
import json
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
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 ROLE_KEYS:
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 ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
vals['role'] = data['role']
if 'active' in data:
vals['active'] = bool(data['active'])
if 'must_change_password' in data:
vals['must_change_password'] = bool(data['must_change_password'])
if 'allowed_menus' in data:
menus = data.get('allowed_menus')
if menus is not None and not isinstance(menus, list):
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
vals['allowed_menus'] = json.dumps(menus) if menus else False
if vals:
user.write(vals)
if data.get('password'):
if len(data['password']) < 8:
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
user.set_password(data['password'])
return json_response({'status': 'success', 'user': user.to_public_dict() | {'active': user.active}})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('admin')
def auth_users_delete(self, user_id, **kw):
try:
user = request.env['skeen.frontend.user'].sudo().browse(user_id)
if not user.exists():
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
if request.frontend_user and request.frontend_user.id == user.id:
return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400)
user.unlink()
return json_response({'status': 'success', 'message': 'Usuario eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Permisos de menú por rol (matriz dinámica)
# ============================================================
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def roles_permissions_get(self, **kw):
try:
return json_response({
'status': 'success',
'matrix': get_menu_roles(request.env),
'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
def roles_permissions_put(self, **kw):
try:
data = _parse_json_body()
matrix = data.get('matrix')
if not isinstance(matrix, dict):
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
clean = {}
for href, roles in matrix.items():
if not isinstance(href, str):
return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400)
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles):
return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400)
clean[href] = list(dict.fromkeys(roles))
if 'admin' not in clean.get('/configuracion', []):
return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400)
request.env['ir.config_parameter'].sudo().set_param(MENU_ROLES_KEY, json.dumps(clean))
return json_response({'status': 'success', 'matrix': clean})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Dashboard
# ============================================================
@http.route('/skeen/frontend/v1/dashboard', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def dashboard_stats(self, **kw):
"""Estadísticas para el dashboard"""
try:
today = date.today()
first_day = today.replace(day=1)
Citas = request.env['skeen.cita'].sudo()
Pacientes = request.env['res.partner'].sudo()
Monederos = request.env['skeen.monedero'].sudo()
Ventas = request.env['skeen.venta'].sudo()
citas_hoy = Citas.search_count([('date', '=', today)])
citas_mes = Citas.search_count([('date', '>=', first_day), ('date', '<=', today)])
pacientes_nuevos_mes = Pacientes.search_count([
('is_patient', '=', True),
('create_date', '>=', first_day.strftime('%Y-%m-%d 00:00:00')),
])
total_pacientes = Pacientes.search_count([('is_patient', '=', True)])
ingresos_mes = sum(v.total for v in Ventas.search([
('date', '>=', first_day),
('date', '<=', today),
('state', 'in', ('paid', 'partial')),
]))
pendientes_pago = Ventas.search_count([('state', 'in', ('confirmed', 'partial')), ('amount_due', '>', 0)])
puntos_totales = sum(m.points for m in Monederos.search([]))
ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))])
return json_response({
'status': 'success',
'stats': {
'appointments_today': citas_hoy,
'appointments_month': citas_mes,
'new_patients_month': pacientes_nuevos_mes,
'total_patients': total_pacientes,
'revenue_month': ingresos_mes,
'pending_payments': pendientes_pago,
'total_wallet_points': puntos_totales,
'sales_today': ventas_hoy,
}
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/weekly_chart', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def weekly_chart(self, **kw):
"""Datos para gráfico semanal"""
try:
today = date.today()
start = today - timedelta(days=today.weekday())
data = []
Citas = request.env['skeen.cita'].sudo()
Ventas = request.env['skeen.venta'].sudo()
for i in range(7):
day = start + timedelta(days=i)
day_citas = Citas.search([('date', '=', day)])
day_ventas = Ventas.search([('date', '=', day), ('state', 'in', ('paid', 'partial'))])
data.append({
'name': day.strftime('%a'),
'appointments': len(day_citas),
'revenue': sum(v.total for v in day_ventas),
'patients': len(set(c.partner_id.id for c in day_citas)),
})
return json_response({'status': 'success', 'data': data})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Citas
# ============================================================
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_appointments(self, **kw):
"""Listar citas (opcionalmente filtradas por fecha, estado, paciente)"""
try:
domain = []
if kw.get('date'):
domain.append(('date', '=', kw.get('date')))
if kw.get('date_from'):
domain.append(('date', '>=', kw.get('date_from')))
if kw.get('date_to'):
domain.append(('date', '<=', kw.get('date_to')))
if kw.get('state'):
domain.append(('state', '=', kw.get('state')))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('search'):
s = kw.get('search')
domain += ['|', '|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s), ('servicio_id.name', 'ilike', s)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Citas = request.env['skeen.cita'].sudo()
total = Citas.search_count(domain)
offset = (page - 1) * page_size
citas = Citas.search(domain, order='date desc, time desc', limit=page_size, offset=offset)
dicts = [self._appointment_to_dict(c) for c in citas]
# Marcar "primera vez": la fecha de la cita coincide con la primera cita del paciente
partner_ids = list({c.partner_id.id for c in citas})
if partner_ids:
groups = Citas.read_group(
[('partner_id', 'in', partner_ids), ('state', '!=', 'cancelled')],
['date:min'], ['partner_id'])
min_dates = {g['partner_id'][0]: str(g['date'])[:10] for g in groups}
for c, d in zip(citas, dicts):
d['is_first_visit'] = bool(c.date) and min_dates.get(c.partner_id.id) == str(c.date)[:10]
return json_response({
'status': 'success',
'appointments': dicts,
'bloqueos': self._bloqueos_rango(domain),
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_appointment(self, **kw):
"""Crear una o más citas (soporta paquetes de sesiones)"""
try:
data = _parse_json_body()
servicio = request.env['skeen.servicio'].sudo().browse(int(data.get('servicio_id', 0)))
sessions = int(data.get('sessions', 1) or 1)
if sessions < 1:
sessions = 1
if servicio.exists() and servicio.package_sessions > 0:
# Si el servicio tiene paquete y no se especificó sessions, default al paquete
sessions = sessions if data.get('sessions') else servicio.package_sessions
base_vals = {
'partner_id': data.get('partner_id'),
'servicio_id': data.get('servicio_id'),
'doctor_id': data.get('doctor_id'),
'branch': data.get('branch', 'rosarito'),
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
}
first_date = data.get('date')
first_time = data.get('time')
created = []
# Para paquetes, generamos citas separadas con fechas consecutivas
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
for i in range(sessions):
vals = dict(base_vals)
vals['date'] = current_date.strftime('%Y-%m-%d')
vals['time'] = first_time if i == 0 else 9.0 # Las siguientes se dejan a las 9:00 AM para reprogramar
cita = request.env['skeen.cita'].sudo().create(vals)
created.append(cita)
current_date += timedelta(days=7 if sessions > 1 else 0)
return json_response({
'status': 'success',
'appointment': self._appointment_to_dict(created[0]),
'appointments': [self._appointment_to_dict(c) for c in created],
'sessions': sessions,
}, 201)
except Exception as e:
# Rollback explícito: evita persistir citas cuyas validaciones fallaron
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
vals = {k: data[k] for k in allowed if k in data}
cita.write(vals)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
# Rollback explícito: evita persistir writes parciales (validación rechazada)
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_appointment(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
cita.unlink()
return json_response({'status': 'success', 'message': 'Cita eliminada'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def update_appointment_status(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
action = data.get('action')
if action == 'confirm':
cita.action_confirm()
elif action == 'arrive':
cita.action_arrive()
elif action == 'start':
cita.action_start()
elif action == 'done':
cita.action_done()
elif action == 'cancel':
cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')})
elif action == 'no_show':
cita.action_no_show()
else:
return json_response({'status': 'error', 'message': 'Acción no válida'}, 400)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def appointment_package_finished(self, appointment_id, **kw):
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
# finished=false revierte la marca (reversible)
finished = data.get('finished', True)
if finished:
cita.action_package_finished()
else:
cita.write({'package_finished': False, 'package_finished_date': False})
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw):
try:
date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0))
if not date_str or not servicio_id:
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
d = datetime.strptime(date_str, '%Y-%m-%d').date()
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id)
return json_response({'status': 'success', 'slots': slots})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Bloqueos de agenda por médico
# ============================================================
def _bloqueos_rango(self, citas_domain):
"""Bloqueos del rango de fechas consultado en list_appointments"""
date_from = date_to = None
for cond in citas_domain:
if isinstance(cond, (list, tuple)) and cond[0] == 'date':
if cond[1] == '=':
date_from = date_to = cond[2]
elif cond[1] == '>=':
date_from = cond[2]
elif cond[1] == '<=':
date_to = cond[2]
if not date_from and not date_to:
date_from = date_to = date.today().strftime('%Y-%m-%d')
domain = []
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, limit=500)
return [self._bloqueo_to_dict(b) for b in bloqueos]
def _bloqueo_to_dict(self, b):
return {
'id': b.id,
'doctor_id': b.doctor_id.id,
'doctor': b.doctor_id.name,
'date': b.date.strftime('%Y-%m-%d') if b.date else None,
'all_day': b.all_day,
'time_from': b.time_from,
'time_to': b.time_to,
'motivo': b.motivo or '',
}
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_bloqueos(self, **kw):
try:
domain = []
if kw.get('date_from'):
domain.append(('date', '>=', kw['date_from']))
if kw.get('date_to'):
domain.append(('date', '<=', kw['date_to']))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc', limit=500)
return json_response({
'status': 'success',
'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_bloqueo(self, **kw):
try:
data = _parse_json_body()
if not data.get('doctor_id') or not data.get('date'):
return json_response({'status': 'error', 'message': 'Médico y fecha son obligatorios'}, 400)
all_day = bool(data.get('all_day'))
time_from = float(data.get('time_from', 9.0) or 9.0)
time_to = float(data.get('time_to', 10.0) or 10.0)
if not all_day and time_to <= time_from:
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
doctor = request.env['hr.employee'].sudo().browse(int(data['doctor_id']))
if not doctor.exists():
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().create({
'doctor_id': doctor.id,
'date': data['date'],
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
'motivo': data.get('motivo', ''),
})
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_bloqueo(self, bloqueo_id, **kw):
try:
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
bloqueo.unlink()
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _appointment_to_dict(self, cita):
return {
'id': cita.id,
'reference': cita.name,
'patient_id': cita.partner_id.id,
'patient': cita.partner_id.name,
'phone': cita.partner_id.phone or '',
'service_id': cita.servicio_id.id,
'service': cita.servicio_id.name,
'service_category': cita.servicio_id.category,
'date': cita.date.strftime('%Y-%m-%d') if cita.date else None,
'time': cita.time_str,
'state': cita.state,
'payment_state': cita.payment_state,
'price': cita.servicio_price,
'amount_paid': cita.amount_paid,
'duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name if cita.doctor_id else None,
'branch': cita.branch,
'medium': cita.medium,
'notes': cita.notes or '',
'is_first_visit': False,
'package_finished': cita.package_finished,
'package_finished_date': cita.package_finished_date.strftime('%Y-%m-%d %H:%M') if cita.package_finished_date else None,
}
# ============================================================
# Visitas clínicas
# ============================================================
@http.route('/skeen/frontend/v1/visitas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_visitas(self, **kw):
"""Listar visitas clínicas (filtros: fecha, estado, paciente, médico, origen, búsqueda)"""
try:
domain = []
fecha = kw.get('date') or date.today().strftime('%Y-%m-%d')
if fecha != 'todas':
domain += [('date_start', '>=', f'{fecha} 00:00:00'), ('date_start', '<=', f'{fecha} 23:59:59')]
if kw.get('state'):
domain.append(('state', '=', kw.get('state')))
if kw.get('origen') in ('cita', 'consulta'):
domain.append(('origen', '=', kw.get('origen')))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('search'):
domain.append(('partner_id.name', 'ilike', kw.get('search')))
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Visitas = request.env['skeen.visita'].sudo()
total = Visitas.search_count(domain)
offset = (page - 1) * page_size
visitas = Visitas.search(domain, order='date_start desc', limit=page_size, offset=offset)
return json_response({
'status': 'success',
'visitas': [self._visita_to_dict(v) for v in visitas],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_visita(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_visita(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
data = _parse_json_body()
allowed = ['motivo', 'diagnostico', 'tratamiento', 'notas', 'receta_text', 'doctor_id', 'cosmetologa_id', 'date_end']
vals = {k: data[k] for k in allowed if k in data}
visita.write(vals)
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def complete_visita(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
visita.action_complete()
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def cancel_visita(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
visita.action_cancel()
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_consulta(self, **kw):
"""Consulta médica directa: visita completada sin cita origen"""
try:
data = _parse_json_body()
partner_id = int(data.get('partner_id', 0) or 0)
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
ahora = fields.Datetime.now()
visita = request.env['skeen.visita'].sudo().create({
'partner_id': partner.id,
'origen': 'consulta',
'state': 'completada',
'date_start': ahora,
'date_end': ahora,
'motivo': data.get('motivo', ''),
'diagnostico': data.get('diagnostico', ''),
'tratamiento': data.get('tratamiento', ''),
'notas': data.get('notas', ''),
'doctor_id': data.get('doctor_id') or False,
})
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def add_visita_insumo(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
if visita.state != 'en_curso':
return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400)
data = _parse_json_body()
qty = float(data.get('qty', 0) or 0)
if qty <= 0:
return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
if not item.exists():
return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
request.env['skeen.visita.insumo'].sudo().create({
'visita_id': visita.id,
'item_id': item.id,
'qty': qty,
'notes': data.get('notes', ''),
})
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos/<int:line_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_visita_insumo(self, visita_id, line_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
if visita.state != 'en_curso':
return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400)
linea = request.env['skeen.visita.insumo'].sudo().browse(line_id)
if not linea.exists() or linea.visita_id.id != visita.id:
return json_response({'status': 'error', 'message': 'Insumo no encontrado'}, 404)
linea.unlink()
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def add_visita_adjunto(self, visita_id, **kw):
try:
visita = request.env['skeen.visita'].sudo().browse(visita_id)
if not visita.exists():
return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404)
if visita.state == 'cancelada':
return json_response({'status': 'error', 'message': 'No se pueden adjuntar archivos a una visita cancelada'}, 400)
data = _parse_json_body()
kind = data.get('kind')
if kind not in ('antes', 'despues', 'documento'):
return json_response({'status': 'error', 'message': 'Tipo de adjunto no válido'}, 400)
file_b64 = data.get('file_b64') or ''
try:
raw = base64.b64decode(file_b64)
except Exception:
return json_response({'status': 'error', 'message': 'Archivo base64 inválido'}, 400)
if not raw:
return json_response({'status': 'error', 'message': 'Archivo vacío'}, 400)
if len(raw) > 10 * 1024 * 1024:
return json_response({'status': 'error', 'message': 'El archivo excede 10 MB'}, 400)
request.env['skeen.visita.adjunto'].sudo().create({
'visita_id': visita.id,
'kind': kind,
'name': data.get('name') or 'archivo',
'file': file_b64,
'mimetype': data.get('mimetype') or '',
'notes': data.get('notes', ''),
})
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_visita_adjunto(self, adj_id, **kw):
try:
adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
if not adj.exists():
return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
visita = adj.visita_id
adj.unlink()
return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>/file', type='http', auth='none', methods=['GET'], csrf=False)
def get_visita_adjunto_file(self, adj_id, **kw):
"""Sirve el binario del adjunto. Acepta Bearer o ?token= (para <img src>)"""
try:
user = current_user(request.env)
if not user:
# Sin header Authorization: validar token de query string
payload = verify_token(request.env, kw.get('token') or '')
if payload:
u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
if u.exists() and u.active:
user = u
if not user:
return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
if not adj.exists() or not adj.file:
return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
return Response(
base64.b64decode(adj.file),
status=200,
mimetype=adj.mimetype or 'application/octet-stream',
headers={
'Content-Disposition': f'inline; filename="{adj.name or "adjunto"}"',
'Access-Control-Allow-Origin': '*',
},
)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _visita_to_dict(self, v):
return {
'id': v.id,
'name': v.name,
'cita_id': v.cita_id.id if v.cita_id else None,
'partner_id': v.partner_id.id,
'patient': v.partner_id.name,
'phone': v.partner_id.phone or '',
'doctor_id': v.doctor_id.id if v.doctor_id else None,
'doctor': v.doctor_id.name if v.doctor_id else None,
'cosmetologa_id': v.cosmetologa_id.id if v.cosmetologa_id else None,
'cosmetologa': v.cosmetologa_id.name if v.cosmetologa_id else None,
'servicio_id': v.servicio_id.id if v.servicio_id else None,
'servicio': v.servicio_id.name if v.servicio_id else None,
'motivo': v.motivo or '',
'diagnostico': v.diagnostico or '',
'tratamiento': v.tratamiento or '',
'notas': v.notas or '',
'receta_text': v.receta_text or '',
'state': v.state,
'origen': v.origen or 'cita',
'date_start': v.date_start.strftime('%Y-%m-%d %H:%M') if v.date_start else None,
'date_end': v.date_end.strftime('%Y-%m-%d %H:%M') if v.date_end else None,
'insumos': [{
'id': l.id,
'item_id': l.item_id.id,
'item': l.item_id.name,
'unit': l.item_id.unit or 'pieza',
'qty': l.qty,
'notes': l.notes or '',
} for l in v.insumo_ids],
'insumos_descargados': v.insumos_descargados,
'adjuntos': [{
'id': a.id,
'kind': a.kind,
'name': a.name or '',
'mimetype': a.mimetype or '',
'notes': a.notes or '',
'create_date': a.create_date.strftime('%Y-%m-%d %H:%M') if a.create_date else None,
'url': f'/skeen/frontend/v1/visitas/adjuntos/{a.id}/file',
} for a in v.adjunto_ids],
}
# ============================================================
# Pacientes
# ============================================================
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_patients(self, **kw):
try:
domain = [('is_patient', '=', True)]
if kw.get('search'):
search = kw.get('search')
domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)]
if kw.get('incomplete') == '1':
domain += [('expediente_completion', '<', 100)]
if kw.get('vip') == '1':
domain.append(('is_vip', '=', True))
if kw.get('doctor_id'):
domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('recent') == '1':
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain.append(('last_visit', '>=', seis_meses))
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Pacientes = request.env['res.partner'].sudo()
total = Pacientes.search_count(domain)
offset = (page - 1) * page_size
patients = Pacientes.search(domain, limit=page_size, offset=offset, order='name asc')
return json_response({
'status': 'success',
'patients': [self._patient_to_dict(p) for p in patients],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_patient(self, **kw):
try:
data = _parse_json_body()
vals = {
'name': data.get('name'),
'phone': data.get('phone'),
'email': data.get('email', ''),
'is_patient': True,
'birth_date': data.get('birth_date'),
'gender': data.get('gender'),
'blood_type': data.get('blood_type'),
'allergies': data.get('allergies', ''),
'medical_history': data.get('medical_history', ''),
'current_medication': data.get('current_medication', ''),
'source': data.get('source', 'whatsapp'),
'primary_doctor_id': data.get('primary_doctor_id'),
'is_vip': data.get('is_vip', False),
# Datos personales extendidos
'birthplace': data.get('birthplace', ''),
'occupation': data.get('occupation', ''),
'marital_status': data.get('marital_status', ''),
'emergency_contact': data.get('emergency_contact', ''),
'emergency_phone': data.get('emergency_phone', ''),
'home_phone': data.get('home_phone', ''),
'mobile': data.get('mobile', ''),
'address_notes': data.get('address_notes', ''),
'referred_by': data.get('referred_by', ''),
'patient_comments': data.get('patient_comments', ''),
'internal_notes': data.get('internal_notes', ''),
# Historia clínica
'is_pregnant': data.get('is_pregnant', False),
'is_breastfeeding': data.get('is_breastfeeding', False),
'uses_contraceptives': data.get('uses_contraceptives', False),
'children_count': data.get('children_count', 0),
'kidney_problems': data.get('kidney_problems', False),
'back_pain': data.get('back_pain', False),
'heart_disease': data.get('heart_disease', False),
'respiratory_problems': data.get('respiratory_problems', False),
'blood_pressure': data.get('blood_pressure', False),
'diabetes': data.get('diabetes', False),
'thyroid': data.get('thyroid', False),
'colitis': data.get('colitis', False),
'constipation': data.get('constipation', False),
'liver_problems': data.get('liver_problems', False),
'surgeries': data.get('surgeries', False),
'surgeries_notes': data.get('surgeries_notes', ''),
'varicose_veins': data.get('varicose_veins', False),
'migraine': data.get('migraine', False),
'faints_with_needles': data.get('faints_with_needles', False),
'medical_notes': data.get('medical_notes', ''),
'whatsapp': data.get('whatsapp', ''),
}
if data.get('photo'):
vals['image_1920'] = data['photo']
patient = request.env['res.partner'].sudo().create(vals)
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_patient(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_patient(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'phone', 'email', 'birth_date', 'gender', 'blood_type',
'allergies', 'medical_history', 'current_medication', 'source',
'primary_doctor_id', 'is_vip',
# Datos personales extendidos
'birthplace', 'occupation', 'marital_status', 'emergency_contact',
'emergency_phone', 'home_phone', 'mobile', 'address_notes',
'referred_by', 'patient_comments', 'internal_notes',
# Historia clínica
'is_pregnant', 'is_breastfeeding', 'uses_contraceptives', 'children_count',
'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems',
'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation',
'liver_problems', 'surgeries', 'surgeries_notes', 'varicose_veins',
'migraine', 'faints_with_needles', 'medical_notes', 'whatsapp']
vals = {k: data[k] for k in allowed if k in data}
if data.get('photo'):
vals['image_1920'] = data['photo']
patient.write(vals)
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/photo', type='http', auth='none', methods=['GET'], csrf=False)
def get_patient_photo(self, patient_id, **kw):
"""Sirve la foto del paciente. Acepta Bearer o ?token= (para <img src>)"""
try:
user = current_user(request.env)
if not user:
# Sin header Authorization: validar token de query string
payload = verify_token(request.env, kw.get('token') or '')
if payload:
u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
if u.exists() and u.active:
user = u
if not user:
return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists() or not patient.image_1920:
return json_response({'status': 'error', 'message': 'Foto no encontrada'}, 404)
raw = base64.b64decode(patient.image_1920)
mimetype = 'image/png' if raw[:8] == b'\x89PNG\r\n\x1a\n' else 'image/jpeg'
return Response(
raw,
status=200,
mimetype=mimetype,
headers={
'Content-Disposition': f'inline; filename="paciente-{patient_id}"',
'Access-Control-Allow-Origin': '*',
},
)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_history(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
visitas = request.env['skeen.visita'].sudo().search([('partner_id', '=', patient_id)], order='date_start desc')
# Estado de cuenta: monedero, gasto total, adeudo y visitas
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', patient_id)], limit=1)
adeudo_rows = request.env['skeen.venta'].sudo().read_group(
[('partner_id', '=', patient_id), ('state', '!=', 'cancelled'), ('amount_due', '>', 0)],
['amount_due:sum'], [])
estado_cuenta = {
'puntos_monedero': monedero.points if monedero else 0,
'total_gastado': patient.total_spent,
'adeudo': adeudo_rows[0]['amount_due'] if adeudo_rows else 0.0,
'visitas_total': patient.total_visits,
}
return json_response({
'status': 'success',
'appointments': [self._appointment_to_dict(c) for c in citas],
'sales': [self._sale_to_dict(v) for v in ventas],
'visitas': [self._visita_to_dict(v) for v in visitas],
'estado_cuenta': estado_cuenta,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def expedientes_recientes(self, **kw):
"""Pacientes con visitas recientes (últimos 30 días por default)"""
try:
domain = [('is_patient', '=', True)]
recencia = kw.get('recencia') or ''
if recencia == '6m':
corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain.append(('last_visit', '>=', corte))
elif recencia == '6m_plus':
corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain += ['|', ('last_visit', '=', False), ('last_visit', '<', corte)]
else:
date_from = kw.get('date_from') or (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
if date_from != 'todas':
domain.append(('last_visit', '>=', date_from))
else:
domain.append(('last_visit', '!=', False))
if kw.get('vip') == '1':
domain.append(('is_vip', '=', True))
if kw.get('doctor_id'):
domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('name', 'ilike', s), ('phone', 'ilike', s)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Partner = request.env['res.partner'].sudo()
total = Partner.search_count(domain)
offset = (page - 1) * page_size
pacientes = Partner.search(domain,
order='last_visit asc' if recencia == '6m_plus' else 'last_visit desc',
limit=page_size, offset=offset)
# Conteo de adjuntos por paciente y tipo en un solo read_group
counts = {}
if pacientes:
grupos = request.env['skeen.patient.adjunto'].sudo().read_group(
[('partner_id', 'in', pacientes.ids)],
['partner_id', 'kind'], ['partner_id', 'kind'], lazy=False)
for g in grupos:
counts.setdefault(g['partner_id'][0], {})[g['kind']] = g['__count']
items = [{
'id': p.id,
'patient_id': p.patient_id,
'name': p.name,
'phone': p.phone or '',
'age': p.age,
'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None,
'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None,
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
'internal_notes': p.internal_notes or '',
'expediente_count': counts.get(p.id, {}).get('expediente', 0),
'imagenes_count': counts.get(p.id, {}).get('imagen', 0),
'has_photo': bool(p.image_1920),
'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo',
} for p in pacientes]
return json_response({
'status': 'success',
'expedientes': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Adjuntos de paciente (expediente escaneado / galería)
# ============================================================
def _patient_adjunto_to_dict(self, a):
return {
'id': a.id,
'kind': a.kind,
'name': a.name or '',
'mimetype': a.mimetype or '',
'notes': a.notes or '',
'create_date': a.create_date.strftime('%Y-%m-%d %H:%M') if a.create_date else None,
'url': f'/skeen/frontend/v1/patients/adjuntos/{a.id}/file',
}
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/adjuntos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_patient_adjuntos(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
domain = [('partner_id', '=', patient_id)]
if kw.get('kind') in ('expediente', 'imagen'):
domain.append(('kind', '=', kw['kind']))
adjs = request.env['skeen.patient.adjunto'].sudo().search(domain)
return json_response({
'status': 'success',
'adjuntos': [self._patient_adjunto_to_dict(a) for a in adjs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def add_patient_adjunto(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
if not patient.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
kind = data.get('kind')
if kind not in ('expediente', 'imagen'):
return json_response({'status': 'error', 'message': 'Tipo de adjunto no válido'}, 400)
file_b64 = data.get('file_b64') or ''
try:
raw = base64.b64decode(file_b64)
except Exception:
return json_response({'status': 'error', 'message': 'Archivo base64 inválido'}, 400)
if not raw:
return json_response({'status': 'error', 'message': 'Archivo vacío'}, 400)
if len(raw) > 10 * 1024 * 1024:
return json_response({'status': 'error', 'message': 'El archivo excede 10 MB'}, 400)
adj = request.env['skeen.patient.adjunto'].sudo().create({
'partner_id': patient.id,
'kind': kind,
'name': data.get('name') or 'archivo',
'file': file_b64,
'mimetype': data.get('mimetype') or '',
'notes': data.get('notes', ''),
})
return json_response({'status': 'success', 'adjunto': self._patient_adjunto_to_dict(adj)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_patient_adjunto(self, adj_id, **kw):
try:
adj = request.env['skeen.patient.adjunto'].sudo().browse(adj_id)
if not adj.exists():
return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
adj.unlink()
return json_response({'status': 'success', 'message': 'Adjunto eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/adjuntos/<int:adj_id>/file', type='http', auth='none', methods=['GET'], csrf=False)
def get_patient_adjunto_file(self, adj_id, **kw):
"""Sirve el binario del adjunto. Acepta Bearer o ?token= (para <img src>)"""
try:
user = current_user(request.env)
if not user:
# Sin header Authorization: validar token de query string
payload = verify_token(request.env, kw.get('token') or '')
if payload:
u = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
if u.exists() and u.active:
user = u
if not user:
return json_response({'status': 'error', 'message': 'No autenticado'}, 401)
adj = request.env['skeen.patient.adjunto'].sudo().browse(adj_id)
if not adj.exists() or not adj.file:
return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404)
return Response(
base64.b64decode(adj.file),
status=200,
mimetype=adj.mimetype or 'application/octet-stream',
headers={
'Content-Disposition': f'inline; filename="{adj.name or "adjunto"}"',
'Access-Control-Allow-Origin': '*',
},
)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Recetas (plantillas prehechas)
# ============================================================
def _receta_to_dict(self, r):
return {
'id': r.id,
'name': r.name,
'categoria': r.categoria or '',
'contenido': r.contenido or '',
}
@http.route('/skeen/frontend/v1/recetas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_recetas(self, **kw):
try:
domain = [('active', '=', True)]
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('name', 'ilike', s), ('categoria', 'ilike', s)]
recetas = request.env['skeen.receta'].sudo().search(domain, order='name asc', limit=500)
return json_response({
'status': 'success',
'recetas': [self._receta_to_dict(r) for r in recetas],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recetas', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_receta(self, **kw):
try:
data = _parse_json_body()
if not data.get('name') or not data.get('contenido'):
return json_response({'status': 'error', 'message': 'Nombre y contenido son obligatorios'}, 400)
receta = request.env['skeen.receta'].sudo().create({
'name': data['name'],
'categoria': data.get('categoria', ''),
'contenido': data['contenido'],
})
return json_response({'status': 'success', 'receta': self._receta_to_dict(receta)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recetas/<int:receta_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_receta(self, receta_id, **kw):
try:
receta = request.env['skeen.receta'].sudo().browse(receta_id)
if not receta.exists():
return json_response({'status': 'error', 'message': 'Receta no encontrada'}, 404)
data = _parse_json_body()
allowed = ['name', 'categoria', 'contenido', 'active']
vals = {k: data[k] for k in allowed if k in data}
receta.write(vals)
return json_response({'status': 'success', 'receta': self._receta_to_dict(receta)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recetas/<int:receta_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('admin')
def delete_receta(self, receta_id, **kw):
try:
receta = request.env['skeen.receta'].sudo().browse(receta_id)
if not receta.exists():
return json_response({'status': 'error', 'message': 'Receta no encontrada'}, 404)
receta.unlink()
return json_response({'status': 'success', 'message': 'Receta eliminada'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Catálogos: Diagnósticos y Procedimientos
# ============================================================
def _catalogo_to_dict(self, r):
return {
'id': r.id,
'name': r.name,
'categoria': r.categoria or '',
'descripcion': r.descripcion or '',
}
def _catalogo_list(self, model, kw):
domain = [('active', '=', True)]
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('name', 'ilike', s), ('categoria', 'ilike', s)]
rows = request.env[model].sudo().search(domain, order='name asc', limit=500)
return json_response({'status': 'success', 'items': [self._catalogo_to_dict(r) for r in rows]})
def _catalogo_create(self, model, data, label):
if not data.get('name'):
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
row = request.env[model].sudo().create({
'name': data['name'],
'categoria': data.get('categoria', ''),
'descripcion': data.get('descripcion', ''),
})
return json_response({'status': 'success', 'item': self._catalogo_to_dict(row)}, 201)
def _catalogo_update(self, model, row_id, data, label):
row = request.env[model].sudo().browse(row_id)
if not row.exists():
return json_response({'status': 'error', 'message': f'{label} no encontrado'}, 404)
allowed = ['name', 'categoria', 'descripcion', 'active']
vals = {k: data[k] for k in allowed if k in data}
row.write(vals)
return json_response({'status': 'success', 'item': self._catalogo_to_dict(row)})
def _catalogo_delete(self, model, row_id, label):
row = request.env[model].sudo().browse(row_id)
if not row.exists():
return json_response({'status': 'error', 'message': f'{label} no encontrado'}, 404)
row.unlink()
return json_response({'status': 'success', 'message': f'{label} eliminado'})
@http.route('/skeen/frontend/v1/diagnosticos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_diagnosticos(self, **kw):
try:
return self._catalogo_list('skeen.diagnostico', kw)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/diagnosticos', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_diagnostico(self, **kw):
try:
return self._catalogo_create('skeen.diagnostico', _parse_json_body(), 'Diagnóstico')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/diagnosticos/<int:row_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_diagnostico(self, row_id, **kw):
try:
return self._catalogo_update('skeen.diagnostico', row_id, _parse_json_body(), 'Diagnóstico')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/diagnosticos/<int:row_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('admin')
def delete_diagnostico(self, row_id, **kw):
try:
return self._catalogo_delete('skeen.diagnostico', row_id, 'Diagnóstico')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/procedimientos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_procedimientos(self, **kw):
try:
return self._catalogo_list('skeen.procedimiento', kw)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/procedimientos', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_procedimiento(self, **kw):
try:
return self._catalogo_create('skeen.procedimiento', _parse_json_body(), 'Procedimiento')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/procedimientos/<int:row_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def update_procedimiento(self, row_id, **kw):
try:
return self._catalogo_update('skeen.procedimiento', row_id, _parse_json_body(), 'Procedimiento')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/procedimientos/<int:row_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('admin')
def delete_procedimiento(self, row_id, **kw):
try:
return self._catalogo_delete('skeen.procedimiento', row_id, 'Procedimiento')
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Búsqueda global (barra del header)
# ============================================================
@http.route('/skeen/frontend/v1/search', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def global_search(self, **kw):
try:
q = (kw.get('q') or '').strip()
if len(q) < 2:
return json_response({'status': 'success', 'patients': [], 'appointments': [], 'services': []})
Partner = request.env['res.partner'].sudo()
patients = Partner.search(
[('is_patient', '=', True), '|', '|',
('name', 'ilike', q), ('phone', 'ilike', q), ('email', 'ilike', q)],
limit=5, order='name asc')
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search(
[('date', '>=', date.today().strftime('%Y-%m-%d')),
('state', 'not in', ('cancelled', 'no_show')), '|',
('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)],
limit=5, order='date asc, time asc')
Servicios = request.env['skeen.servicio'].sudo()
servicios = Servicios.search(
[('active', '=', True), ('price', '>', 0), ('name', 'ilike', q)],
limit=5, order='name asc')
return json_response({
'status': 'success',
'patients': [{
'id': p.id, 'name': p.name, 'phone': p.phone or '',
} for p in patients],
'appointments': [{
'id': c.id, 'patient': c.partner_id.name, 'service': c.servicio_id.name,
'date': c.date.strftime('%Y-%m-%d') if c.date else None, 'time': c.time_str,
} for c in citas],
'services': [{
'id': s.id, 'name': s.name, 'price': s.price,
} for s in servicios],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _patient_to_dict(self, p):
return {
'id': p.id, 'patient_id': p.patient_id,
'name': p.name,
'phone': p.phone or '',
'email': p.email or '',
'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None,
'age': p.age,
'gender': p.gender or '',
'blood_type': p.blood_type or '',
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
'total_visits': p.total_visits,
'wallet_points': p.wallet_points,
'total_spent': p.total_spent,
'source': p.source or '',
'is_vip': p.is_vip,
'primary_doctor_id': p.primary_doctor_id.id if p.primary_doctor_id else None,
'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None,
'whatsapp': p.whatsapp or '',
'has_photo': bool(p.image_1920),
'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo',
# Datos personales extendidos
'birthplace': p.birthplace or '',
'occupation': p.occupation or '',
'marital_status': p.marital_status or '',
'emergency_contact': p.emergency_contact or '',
'emergency_phone': p.emergency_phone or '',
'home_phone': p.home_phone or '',
'mobile': p.mobile or '',
'address_notes': p.address_notes or '',
'referred_by': p.referred_by or '',
'patient_comments': p.patient_comments or '',
'internal_notes': p.internal_notes or '',
# Historia clínica
'is_pregnant': p.is_pregnant,
'is_breastfeeding': p.is_breastfeeding,
'uses_contraceptives': p.uses_contraceptives,
'children_count': p.children_count,
'kidney_problems': p.kidney_problems,
'back_pain': p.back_pain,
'heart_disease': p.heart_disease,
'respiratory_problems': p.respiratory_problems,
'blood_pressure': p.blood_pressure,
'diabetes': p.diabetes,
'thyroid': p.thyroid,
'colitis': p.colitis,
'constipation': p.constipation,
'liver_problems': p.liver_problems,
'surgeries': p.surgeries,
'surgeries_notes': p.surgeries_notes or '',
'varicose_veins': p.varicose_veins,
'migraine': p.migraine,
'faints_with_needles': p.faints_with_needles,
'medical_notes': p.medical_notes or '',
# Completitud de expediente
'expediente_completion': p.expediente_completion,
'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [],
}
# ============================================================
# Servicios
# ============================================================
@http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def list_services(self, **kw):
try:
domain = [('active', '=', True), ('price', '>', 0), ('duration_min', '>', 0)]
if kw.get('search'):
search = kw.get('search')
domain += ['|', ('name', 'ilike', search), ('code', 'ilike', search)]
if kw.get('category'):
domain += [('category', '=', kw.get('category'))]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200)
Servicio = request.env['skeen.servicio'].sudo()
total = Servicio.search_count(domain)
services = Servicio.search(domain, order='sequence asc, name asc',
limit=page_size, offset=(page - 1) * page_size)
return json_response({
'status': 'success',
'services': [self._service_to_dict(s) for s in services],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('admin')
def create_service(self, **kw):
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
Servicio = request.env['skeen.servicio'].sudo()
existente = Servicio.search([('name', '=ilike', name)], limit=1)
if existente:
return json_response({
'status': 'error',
'message': f'Ya existe un servicio con ese nombre (ID {existente.id}). Edítalo en vez de duplicarlo.',
}, 409)
code = (data.get('code') or '').strip()
if code:
dup_code = Servicio.search([('code', '=', code)], limit=1)
if dup_code:
return json_response({
'status': 'error',
'message': f'El código {code} ya está en uso por "{dup_code.name}".',
}, 409)
vals = {
'name': name,
'code': code,
'category': data.get('category', 'tratamiento'),
'price': data.get('price', 0.0),
'duration_min': data.get('duration_min', 30),
'package_price': data.get('package_price', 0.0),
'package_sessions': data.get('package_sessions', 0),
'package_notes': data.get('package_notes', ''),
'description': data.get('description', ''),
'color': data.get('color', '#1abc9c'),
'service_group': data.get('service_group', ''),
'is_favorite': data.get('is_favorite', False),
}
service = Servicio.create(vals)
return json_response({'status': 'success', 'service': self._service_to_dict(service)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('admin')
def update_service(self, service_id, **kw):
try:
service = request.env['skeen.servicio'].sudo().browse(service_id)
if not service.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active']
vals = {k: data[k] for k in allowed if k in data}
Servicio = request.env['skeen.servicio'].sudo()
if vals.get('name'):
dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1)
if dup:
return json_response({'status': 'error', 'message': f'Ya existe otro servicio con ese nombre (ID {dup.id}).'}, 409)
if vals.get('code'):
dupc = Servicio.search([('code', '=', vals['code']), ('id', '!=', service_id)], limit=1)
if dupc:
return json_response({'status': 'error', 'message': f'El código {vals["code"]} ya está en uso por "{dupc.name}".'}, 409)
service.write(vals)
return json_response({'status': 'success', 'service': self._service_to_dict(service)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _service_to_dict(self, s):
return {
'id': s.id,
'code': s.code,
'name': s.name,
'category': s.category,
'price': s.price,
'package_price': s.package_price,
'package_sessions': s.package_sessions,
'package_notes': s.package_notes or '',
'duration_min': s.duration_min,
'description': s.description or '',
'color': s.color,
'service_group': s.service_group or '',
'is_favorite': s.is_favorite,
'genera_monedero': s.genera_monedero,
}
# ============================================================
# Médicos
# ============================================================
@http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def list_doctors(self, **kw):
"""Médicos + estadísticas de ventas de los últimos 30 días.
- ventas_30d: ventas distintas (state != cancelled) con >=1 línea
recetada por el médico (prescribed_by_id), fecha >= hoy-30.
- total_30d: suma del subtotal de las líneas recetadas (misma métrica
que el reporte de comisiones).
- total_30d_usd: total_30d / TC vigente (None si no hay TC).
- card_30d: pagado con tarjeta física. Exacto para ventas nuevas vía
venta.card_amount (se llena en pay_sale y POS checkout). Aproximación
histórica para ventas anteriores: si la venta tiene cita_id, se suman
los pagos completed con method='card' ligados a esa cita, topados al
amount_paid de la venta (por eso es aproximada: el pago no liga a la
venta, solo a la cita). Stripe/MercadoPago no cuentan como tarjeta.
"""
try:
doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100)
# Pacientes asignados por médico (primary_doctor_id)
counts = {}
for g in request.env['res.partner'].sudo().read_group(
[('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)],
[], ['primary_doctor_id']):
counts[g['primary_doctor_id'][0]] = g['primary_doctor_id_count']
# ---- Ventas de los últimos 30 días por médico ----
since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
stats = {} # doctor_id -> {'ventas': set, 'total': float}
card_by_venta = {}
ventas = request.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'), ('date', '>=', since),
])
if ventas:
for r in request.env['skeen.venta.line'].sudo().search_read(
[('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)],
['prescribed_by_id', 'venta_id', 'subtotal']):
did = r['prescribed_by_id'][0]
st = stats.setdefault(did, {'ventas': set(), 'total': 0.0})
st['ventas'].add(r['venta_id'][0])
st['total'] += r['subtotal'] or 0.0
# Tarjeta: exacto (card_amount) + aprox histórica por cita
ventas_by_id = {v.id: v for v in ventas}
for vid, v in ventas_by_id.items():
card_by_venta[vid] = v.card_amount or 0.0
citas = ventas.mapped('cita_id')
if citas:
pgroup = request.env['skeen.pago'].sudo().read_group(
[('cita_id', 'in', citas.ids), ('payment_method', '=', 'card'),
('state', '=', 'completed')],
['cita_id', 'amount:sum'], ['cita_id'])
card_by_cita = {g['cita_id'][0]: g['amount'] for g in pgroup}
for v in ventas:
if v.cita_id:
aprox = min(card_by_cita.get(v.cita_id.id, 0.0), v.amount_paid or 0.0)
card_by_venta[v.id] = max(v.card_amount or 0.0, aprox)
# TC vigente (para el equivalente USD)
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate = tc.rate if tc else None
return json_response({
'status': 'success',
'doctors': [{
'id': d.id,
'name': d.name,
'job_title': d.job_title or '',
'work_phone': d.work_phone or '',
'work_email': d.work_email or '',
'commission_pct': d.commission_pct or 0.0,
'patient_count': counts.get(d.id, 0),
'ventas_30d': len(stats.get(d.id, {}).get('ventas', set())),
'total_30d': round(stats.get(d.id, {}).get('total', 0.0), 2),
'total_30d_usd': round(stats.get(d.id, {}).get('total', 0.0) / rate, 2) if rate else None,
'card_30d': round(sum(card_by_venta.get(vid, 0.0) for vid in stats.get(d.id, {}).get('ventas', set())), 2),
} 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(),
})
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
vals = {'amount_paid': sale.amount_paid + amount}
if method == 'card':
vals['card_amount'] = sale.card_amount + amount
sale.write(vals)
if sale.amount_paid >= sale.total:
sale.action_mark_paid()
else:
sale.write({'state': 'partial'})
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'payment': self._payment_to_dict(payment)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def refund_sale(self, sale_id, **kw):
try:
sale = request.env['skeen.venta'].sudo().browse(sale_id)
if not sale.exists():
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
data = _parse_json_body()
amount = data.get('amount') # None o <=0 => devolución total de lo pagado
reason = data.get('reason', '')
result = sale.action_refund(amount=amount, reason=reason)
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _sale_to_dict(self, v):
return {
'id': v.id,
'name': v.name,
'patient_id': v.partner_id.id,
'patient': v.partner_id.name,
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
'subtotal': v.subtotal,
'discount': v.discount,
'tax': v.tax,
'total': v.total,
'amount_paid': v.amount_paid,
'amount_due': v.amount_due,
'state': v.state,
'refunded': v.refunded,
'refund_amount': v.refund_amount,
'refund_reason': v.refund_reason or '',
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
'lines': [{
'id': line.id,
'service_id': line.service_id.id if line.service_id else None,
'service': line.service_id.name if line.service_id else '',
'item_id': line.item_id.id if line.item_id else None,
'item': line.item_id.name if line.item_id else '',
'description': line.description or '',
'quantity': line.quantity,
'price_unit': line.price_unit,
'subtotal': line.subtotal,
'is_prescribed': line.is_prescribed,
'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None,
'prescribed_by': line.prescribed_by_id.name if line.prescribed_by_id else None,
} for line in v.line_ids],
}
# ============================================================
# POS: cobro rápido (venta + pago en una operación)
# ============================================================
@http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def pos_checkout(self, **kw):
"""Crea la venta confirmada, aplica canje de puntos y/o registra el pago.
- pay_with_points: canjea puntos del monedero (1 pt = $1) por el total.
A diferencia de action_apply_wallet (tope 50%), aquí el canje puede
cubrir el 100% porque es la forma de pago elegida en caja.
- action_mark_paid acumula puntos automáticamente (1 pt por $10 del
total restante); no se duplica acumulación.
"""
try:
data = _parse_json_body()
lines = data.get('lines') or []
if not lines:
return json_response({'status': 'error', 'message': 'La venta no tiene líneas'}, 400)
partner = request.env['res.partner'].sudo().browse(int(data.get('partner_id', 0) or 0))
if not partner.exists():
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
discount = float(data.get('discount', 0) or 0)
line_vals = []
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
stock_warnings = []
subtotal = 0.0
for l in lines:
qty = float(l.get('quantity', 0) or 0)
price = float(l.get('price_unit', 0) or 0)
if qty <= 0:
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
service_id = int(l.get('service_id', 0) or 0)
item_id = int(l.get('item_id', 0) or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
if item_id:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists() or not item.active:
return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404)
line_vals.append((0, 0, {
'item_id': item.id,
'description': l.get('description') or item.name,
'quantity': qty,
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
item_lines.append((item, qty))
if qty > (item.qty or 0):
stock_warnings.append(f'{item.name}: existencia {item.qty:g}, se vendieron {qty:g}')
else:
servicio = request.env['skeen.servicio'].sudo().browse(service_id)
if not servicio.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
line_vals.append((0, 0, {
'service_id': servicio.id,
'description': l.get('description') or servicio.name,
'quantity': qty,
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
total_est = max(0.0, subtotal - discount)
# Moneda y tipo de cambio: validar ANTES de crear nada
currency = data.get('currency', 'MXN')
rate_used = None
if currency == 'USD':
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
if not tc:
return json_response({'status': 'error', 'message': 'Configura el tipo de cambio USD en Configuración'}, 400)
rate_used = tc.rate
elif currency != 'MXN':
return json_response({'status': 'error', 'message': 'Moneda no soportada (MXN/USD)'}, 400)
# Validar saldo de puntos ANTES de crear nada
pay_with_points = bool(data.get('pay_with_points'))
Monedero = request.env['skeen.monedero'].sudo()
monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1)
saldo = monedero.points if monedero else 0
if pay_with_points and saldo < total_est:
return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400)
method = data.get('payment_method', 'cash')
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
venta = request.env['skeen.venta'].sudo().create({
'partner_id': partner.id,
'discount': discount,
'line_ids': line_vals,
})
venta.action_confirm()
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
if item_lines:
Move = request.env['skeen.inventario.move'].sudo()
for item, qty in item_lines:
Move.create({
'item_id': item.id,
'type': 'venta',
'qty': qty,
'reference': venta.name,
'notes': 'Venta POS',
})
# Canje de puntos como forma de pago (hasta cubrir el total)
puntos_usados = 0
if pay_with_points:
total = venta.total
puntos = int(total) if float(total).is_integer() else int(total) + 1
puntos = min(puntos, saldo)
monedero.redeem_points(puntos, description=f'Canje puntos venta {venta.name}', reference=venta.name)
venta.write({'points_redeemed': puntos, 'points_discount': float(puntos)})
puntos_usados = puntos
# Pago monetario del restante
restante = round(venta.amount_due, 2)
pago = None
cambio = 0.0
cambio_usd = 0.0
recibido_usd = 0.0
if restante > 0:
received = float(data.get('amount_received', restante) or restante)
reference = ''
if currency == 'USD':
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
recibido_usd = received
received = round(recibido_usd * rate_used, 2)
reference = f'USD {recibido_usd:.2f} @ TC {rate_used:.2f}'
pay_amount = restante if received >= restante - 0.001 else received
cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
if rate_used:
cambio_usd = round(cambio / rate_used, 2)
pago = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id,
'amount': pay_amount,
'payment_method': method,
'state': 'completed',
'payment_date': fields.Datetime.now(),
'provider_reference': reference,
})
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
vals = {'amount_paid': venta.amount_paid + pay_amount}
if method == 'card':
vals['card_amount'] = venta.card_amount + pay_amount
venta.write(vals)
if venta.amount_paid >= venta.total:
venta.action_mark_paid()
else:
venta.write({'state': 'partial'})
else:
venta.action_mark_paid()
partner.invalidate_recordset()
return json_response({
'status': 'success',
'sale': self._sale_to_dict(venta),
'payment': self._payment_to_dict(pago) if pago else None,
'cambio': cambio,
'cambio_usd': cambio_usd,
'rate_used': rate_used,
'recibido_usd': recibido_usd,
'puntos_usados': puntos_usados,
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'stock_warnings': stock_warnings,
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Cortes de Caja
# ============================================================
@http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_cash_closings(self, **kw):
try:
domain = []
if kw.get('date'):
domain.append(('date', '=', kw.get('date')))
closings = request.env['skeen.corte.caja'].sudo().search(domain, order='date desc', limit=100)
return json_response({
'status': 'success',
'cash_closings': [self._cash_closing_to_dict(c) for c in closings],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_cash_closing(self, **kw):
try:
data = _parse_json_body()
vals = {
'date': data.get('date', fields.Date.today()),
'opening_cash': data.get('opening_cash', 0.0),
'notes': data.get('notes', ''),
}
closing = request.env['skeen.corte.caja'].sudo().create(vals)
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def close_cash_closing(self, closing_id, **kw):
try:
closing = request.env['skeen.corte.caja'].sudo().browse(closing_id)
if not closing.exists():
return json_response({'status': 'error', 'message': 'Corte no encontrado'}, 404)
data = _parse_json_body()
if 'closing_cash' in data:
closing.write({'closing_cash': data['closing_cash']})
closing.action_close()
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _cash_closing_to_dict(self, c):
return {
'id': c.id,
'name': c.name,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'user': c.user_id.name,
'opening_cash': c.opening_cash,
'total_cash': c.total_cash,
'total_card': c.total_card,
'total_transfer': c.total_transfer,
'total_other': c.total_other,
'total_sales': c.total_sales,
'closing_cash': c.closing_cash,
'difference': c.difference,
'state': c.state,
}
# ============================================================
# Reportes
# ============================================================
@http.route('/skeen/frontend/v1/reports/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_sales(self, **kw):
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
Ventas = request.env['skeen.venta'].sudo()
ventas = Ventas.search([('date', '>=', start), ('date', '<=', end)])
# Ingresos agrupados por día (clave nueva; lo demás no cambia)
by_day_map = {}
for v in ventas:
key = v.date.strftime('%Y-%m-%d')
by_day_map[key] = by_day_map.get(key, 0.0) + v.total
return json_response({
'status': 'success',
'start': start,
'end': end,
'total_sales': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
'count': len(ventas),
'by_day': [{'date': k, 'total': by_day_map[k]} for k in sorted(by_day_map)],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/adeudos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_adeudos(self, **kw):
"""Cartera por cobrar: adeudo agrupado por paciente (top 200)"""
try:
Ventas = request.env['skeen.venta'].sudo()
rows = Ventas.read_group(
[('amount_due', '>', 0), ('state', '!=', 'cancelled')],
['partner_id', 'amount_due:sum', 'date:max'], ['partner_id'])
items = []
for r in rows:
pid = r['partner_id'][0]
items.append({
'id': pid,
'name': r['partner_id'][1],
'num_ventas': r.get('__count') or r.get('partner_id_count', 0),
'total_adeudo': r['amount_due'],
'ultima_venta': str(r['date'])[:10] if r.get('date') else None,
})
phones = {}
if items:
for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]):
phones[p.id] = p.phone or ''
for i in items:
i['phone'] = phones.get(i['id'], '')
items.sort(key=lambda x: -x['total_adeudo'])
total_cartera = sum(i['total_adeudo'] for i in items)
return json_response({
'status': 'success',
'adeudos': items[:200],
'total_cartera': total_cartera,
'total_pacientes': len(items),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/daily', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_daily(self, **kw):
"""Resumen de movimientos del día: citas, ventas, cobros e inventario"""
try:
date_str = kw.get('date', date.today().strftime('%Y-%m-%d'))
# Citas del día por estado
citas = request.env['skeen.cita'].sudo().search([('date', '=', date_str)])
citas_by_state = {}
for c in citas:
citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
# Ventas del día
ventas = request.env['skeen.venta'].sudo().search([('date', '=', date_str)])
# Cobros del día por método (misma convención que report_cash)
pagos = request.env['skeen.pago'].sudo().search([
('payment_date', '>=', f'{date_str} 00:00:00'),
('payment_date', '<=', f'{date_str} 23:59:59'),
('state', '=', 'completed'),
])
pagos_by_method = {}
for p in pagos:
pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount
# Movimientos de inventario del día por tipo
moves = request.env['skeen.inventario.move'].sudo().search([
('date', '>=', f'{date_str} 00:00:00'),
('date', '<=', f'{date_str} 23:59:59'),
])
moves_by_type = {}
for mv in moves:
moves_by_type[mv.type] = moves_by_type.get(mv.type, 0) + 1
response = {
'status': 'success',
'date': date_str,
'citas': {'total': len(citas), 'by_state': citas_by_state},
'ventas': {
'count': len(ventas),
'total': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
},
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
'inventario': {'total': len(moves), 'by_type': moves_by_type},
}
# Desglose opcional por médico o por usuario de recepción
by = kw.get('by')
if by == 'medico':
response['por_medico'] = self._daily_por_medico(citas, ventas, pagos)
elif by == 'recepcion':
response['por_recepcion'] = self._daily_por_recepcion(ventas, pagos)
return json_response(response)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _daily_por_medico(self, citas, ventas, pagos):
"""Desglose del día por médico: citas (por estado), líneas recetadas y cobros de sus citas"""
por_medico = {}
for c in citas:
doc = c.doctor_id.name if c.doctor_id else 'Sin médico'
entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
entry['citas'] += 1
entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1
# Ventas del día atribuidas al médico vía líneas recetadas
if ventas:
lineas = request.env['skeen.venta.line'].sudo().search([
('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False),
])
for l in lineas:
doc = l.prescribed_by_id.name
entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
entry['ventas'] += l.subtotal
# Cobros del día ligados a cita con médico
for p in pagos:
if p.cita_id and p.cita_id.doctor_id:
doc = p.cita_id.doctor_id.name
entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0})
entry['cobros'] += p.amount
rows = [{'medico': k, **v} for k, v in por_medico.items()]
rows.sort(key=lambda r: -(r['ventas'] + r['cobros'] + r['citas']))
return rows
def _daily_por_recepcion(self, ventas, pagos):
"""Desglose del día por usuario que registró la venta / cobro (create_uid)"""
por_usuario = {}
for v in ventas:
usr = v.create_uid.name
entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0})
entry['ventas_count'] += 1
entry['ventas_total'] += v.total
for p in pagos:
usr = p.create_uid.name
entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0})
entry['cobros_total'] += p.amount
rows = [{'usuario': k, **v} for k, v in por_usuario.items()]
rows.sort(key=lambda r: -(r['ventas_total'] + r['cobros_total']))
return rows
@http.route('/skeen/frontend/v1/reports/comisiones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_comisiones(self, **kw):
"""Comisiones por empleado: líneas recetadas + citas completadas"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
ventas = request.env['skeen.venta'].sudo().search([
('state', 'in', ['confirmed', 'paid', 'partial']),
('date', '>=', start), ('date', '<=', end),
])
rows = []
if ventas:
Lineas = request.env['skeen.venta.line'].sudo()
line_domain = [('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)]
grouped = Lineas.read_group(line_domain, ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id'])
ventas_por_doc = {}
items_por_doc = {}
for r in Lineas.search_read(line_domain, ['prescribed_by_id', 'venta_id']):
did = r['prescribed_by_id'][0]
ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0])
items_por_doc[did] = items_por_doc.get(did, 0) + 1
# Citas completadas por doctor en el rango
citas_por_doc = {}
for g in request.env['skeen.cita'].sudo().read_group(
[('state', '=', 'done'), ('date', '>=', start), ('date', '<=', end), ('doctor_id', '!=', False)],
['doctor_id'], ['doctor_id']):
citas_por_doc[g['doctor_id'][0]] = g.get('__count') or g.get('doctor_id_count', 0)
Emp = request.env['hr.employee'].sudo()
for g in grouped:
did = g['prescribed_by_id'][0]
emp = Emp.browse(did)
base = g['subtotal'] or 0.0
pct = emp.commission_pct or 0.0
rows.append({
'doctor_id': emp.id,
'doctor': emp.name,
'job_title': emp.job_title or '',
'items': items_por_doc.get(did, 0),
'sales': len(ventas_por_doc.get(did, set())),
'citas_done': citas_por_doc.get(did, 0),
'total_vendido': base,
'commission_pct': pct,
'comision': base * pct / 100.0,
})
rows.sort(key=lambda r: -r['comision'])
return json_response({
'status': 'success',
'start': start,
'end': end,
'comisiones': rows,
'total_vendido': sum(r['total_vendido'] for r in rows),
'total_comision': sum(r['comision'] for r in rows),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/pagos-servicios', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_pagos_servicios(self, **kw):
"""Cobros del periodo y desglose por servicio de las ventas totalmente pagadas.
skeen.pago no tiene relación directa con venta/servicio, así que el
desglose por servicio usa las ventas state='paid' del rango (subtotal
por línea). El KPI de cobrado usa los pagos reales del periodo.
"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
pagos = request.env['skeen.pago'].sudo().search([
('payment_date', '>=', f'{start} 00:00:00'),
('payment_date', '<=', f'{end} 23:59:59'),
('state', '=', 'completed'),
])
by_method = {}
for p in pagos:
by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount
# Desglose por servicio: líneas de ventas totalmente pagadas en el rango
ventas_paid = request.env['skeen.venta'].sudo().search([
('state', '=', 'paid'), ('date', '>=', start), ('date', '<=', end),
])
servicios = {}
if ventas_paid:
grouped = request.env['skeen.venta.line'].sudo().read_group(
[('venta_id', 'in', ventas_paid.ids)],
['service_id', 'subtotal:sum', 'quantity:sum'], ['service_id'])
for g in grouped:
sid = g['service_id'][0] if g['service_id'] else 0
servicios[sid] = {
'servicio': g['service_id'][1] if g['service_id'] else 'Sin servicio',
'cantidad': g['quantity'],
'total': g['subtotal'],
}
rows = sorted(servicios.values(), key=lambda x: -x['total'])
return json_response({
'status': 'success',
'start': start,
'end': end,
'total_cobrado': sum(p.amount for p in pagos),
'total_pagos': len(pagos),
'by_method': by_method,
'servicios': rows,
'total_pagado_completo': sum(r['total'] for r in rows),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/pagos-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_pagos_clientes(self, **kw):
"""Cobros agrupados por cliente (top 200)"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
rows = request.env['skeen.pago'].sudo().read_group([
('payment_date', '>=', f'{start} 00:00:00'),
('payment_date', '<=', f'{end} 23:59:59'),
('state', '=', 'completed'),
], ['partner_id', 'amount:sum', 'payment_date:max'], ['partner_id'])
items = [{
'id': r['partner_id'][0],
'name': r['partner_id'][1],
'num_pagos': r.get('__count') or r.get('partner_id_count', 0),
'total_cobrado': r['amount'],
'ultimo_pago': str(r['payment_date'])[:16] if r.get('payment_date') else None,
} for r in rows]
items.sort(key=lambda x: -x['total_cobrado'])
return json_response({
'status': 'success',
'start': start,
'end': end,
'clientes': items[:200],
'total_cobrado': sum(i['total_cobrado'] for i in items),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/devoluciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_devoluciones(self, **kw):
"""Ventas devueltas en el periodo"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
ventas = request.env['skeen.venta'].sudo().search([
('refunded', '=', True), ('date', '>=', start), ('date', '<=', end),
], order='date desc')
rows = [{
'id': v.id,
'folio': v.name,
'paciente': v.partner_id.name,
'fecha': v.date.strftime('%Y-%m-%d') if v.date else None,
'total': v.total,
'refund_amount': v.refund_amount,
'refund_reason': v.refund_reason or '',
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
} for v in ventas]
return json_response({
'status': 'success',
'start': start,
'end': end,
'devoluciones': rows,
'total_devuelto': sum(r['refund_amount'] for r in rows),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/top-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_top_clientes(self, **kw):
"""Clientes con más compras en el periodo"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
limit = min(max(int(kw.get('limit', 20) or 20), 1), 200)
rows = request.env['skeen.venta'].sudo().read_group(
[('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)],
['partner_id', 'total:sum'], ['partner_id'])
items = []
for r in rows:
n = r.get('__count') or r.get('partner_id_count', 0)
items.append({
'id': r['partner_id'][0],
'name': r['partner_id'][1],
'num_ventas': n,
'total': r['total'],
'ticket_promedio': (r['total'] / n) if n else 0.0,
})
items.sort(key=lambda x: -x['total'])
return json_response({
'status': 'success',
'start': start,
'end': end,
'clientes': items[:limit],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/kpis', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_kpis(self, **kw):
"""KPIs clínicos: no-show, ocupación por médico, conversión de leads, primera vez"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled')])
# No-show: tasa global y por médico
total_citas = len(citas)
total_no_show = len(citas.filtered(lambda c: c.state == 'no_show'))
por_medico = {}
for c in citas:
doc = c.doctor_id.name if c.doctor_id else 'Sin médico'
entry = por_medico.setdefault(doc, {'citas': 0, 'no_shows': 0})
entry['citas'] += 1
if c.state == 'no_show':
entry['no_shows'] += 1
noshow_medico = [{
'medico': k,
'citas': v['citas'],
'no_shows': v['no_shows'],
'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0,
} for k, v in por_medico.items()]
noshow_medico.sort(key=lambda x: -x['tasa'])
# Ocupación por médico: horas vendidas vs disponibles (días no domingo × 8h bloqueos)
d1 = datetime.strptime(start, '%Y-%m-%d').date()
d2 = datetime.strptime(end, '%Y-%m-%d').date()
dias_habiles = sum(1 for i in range((d2 - d1).days + 1)
if (d1 + timedelta(days=i)).weekday() != 6)
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search([
('date', '>=', start), ('date', '<=', end),
])
bloqueo_horas = {}
for b in bloqueos:
if b.date.weekday() == 6:
continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
ventas_medico = {}
for c in citas:
if not c.doctor_id:
continue
dur = (c.servicio_id.duration_min if c.servicio_id else 30) or 30
ventas_medico[c.doctor_id.name] = ventas_medico.get(c.doctor_id.name, 0.0) + dur / 60.0
ocupacion = []
for medico, vendidas in ventas_medico.items():
disponibles = dias_habiles * 8.0 - bloqueo_horas.get(medico, 0.0)
disponibles = max(disponibles, 0.0)
pct = vendidas / disponibles if disponibles > 0 else 0
ocupacion.append({
'medico': medico,
'horas_vendidas': round(vendidas, 2),
'horas_disponibles': round(disponibles, 2),
'horas_libres': round(max(disponibles - vendidas, 0.0), 2),
'ocupacion': round(min(pct, 1.0), 4),
})
ocupacion.sort(key=lambda x: -x['ocupacion'])
ocup_promedio = (sum(o['ocupacion'] for o in ocupacion) / len(ocupacion)) if ocupacion else 0
# Conversión de leads WACRM (created_at en el rango)
deals = request.env['skeen.wacrm.deal'].sudo().search([
('created_at', '>=', f'{start} 00:00:00'),
('created_at', '<=', f'{end} 23:59:59'),
])
won = deals.filtered(lambda d: d.status == 'won')
tiempos = []
for d in won:
if d.created_at and d.updated_at and d.updated_at > d.created_at:
tiempos.append((d.updated_at - d.created_at).total_seconds() / 86400.0)
conversion = {
'total': len(deals),
'ganados': len(won),
'tasa': round(len(won) / len(deals), 4) if deals else 0,
'dias_promedio_ganar': round(sum(tiempos) / len(tiempos), 1) if tiempos else None,
}
# Primera vez vs subsecuentes (primera cita del paciente, misma lógica que list_appointments)
primera = 0
if citas:
partner_ids = list({c.partner_id.id for c in citas})
groups = Citas.read_group(
[('partner_id', 'in', partner_ids), ('state', '!=', 'cancelled')],
['date:min'], ['partner_id'])
min_dates = {g['partner_id'][0]: str(g['date'])[:10] for g in groups}
primera = sum(1 for c in citas if min_dates.get(c.partner_id.id) == str(c.date)[:10])
return json_response({
'status': 'success',
'start': start,
'end': end,
'no_show': {
'tasa': round(total_no_show / total_citas, 4) if total_citas else 0,
'total_citas': total_citas,
'total_no_shows': total_no_show,
'por_medico': noshow_medico,
},
'ocupacion': {
'promedio': round(ocup_promedio, 4),
'dias_habiles': dias_habiles,
'por_medico': ocupacion,
},
'leads': conversion,
'primera_vez': {
'primera': primera,
'subsecuentes': total_citas - primera,
'tasa': round(primera / total_citas, 4) if total_citas else 0,
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/notifications', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def notifications(self, **kw):
"""Alertas dinámicas para la campanita del header (calculadas al momento)"""
try:
hoy = date.today()
items = []
Item = request.env['skeen.inventario.item'].sudo()
# conteo exacto con la misma regla de /inventario/alertas (bajo mínimo o sin existencia)
stock_bajo = 0
for it in Item.search([('active', '=', True)]):
qty = it.qty or 0.0
qty_min = it.qty_min or 0.0
if qty < qty_min or (qty_min == 0 and qty == 0):
stock_bajo += 1
if stock_bajo:
items.append({
'tipo': 'stock_bajo',
'titulo': 'Stock bajo',
'detalle': f'{stock_bajo} artículos bajo su mínimo',
'link': '/inventario?tab=alertas',
'severity': 'warning',
})
caduc = Item.search_count([
('active', '=', True), ('expiry_date', '!=', False),
('expiry_date', '<=', (hoy + timedelta(days=30)).strftime('%Y-%m-%d')),
])
if caduc:
items.append({
'tipo': 'caducidades',
'titulo': 'Caducidades',
'detalle': f'{caduc} artículos vencidos o por vencer en 30 días',
'link': '/inventario?tab=alertas',
'severity': 'danger',
})
pendientes_hoy = request.env['skeen.cita'].sudo().search_count([
('date', '=', hoy.strftime('%Y-%m-%d')), ('state', '=', 'pending'),
])
if pendientes_hoy:
items.append({
'tipo': 'citas_pendientes_hoy',
'titulo': 'Citas por confirmar',
'detalle': f'{pendientes_hoy} citas pendientes hoy',
'link': '/agenda',
'severity': 'info',
})
leads_sin_asignar = request.env['skeen.wacrm.deal'].sudo().search_count([
('status', '=', 'open'),
'|', ('assigned_to', '=', False), ('assigned_to', '=', ''),
])
if leads_sin_asignar:
items.append({
'tipo': 'leads_sin_asignar',
'titulo': 'Leads sin asignar',
'detalle': f'{leads_sin_asignar} leads abiertos sin agente',
'link': '/wacrm/leads',
'severity': 'warning',
})
convers = request.env['skeen.wacrm.conversation'].sudo().search([('unread_count', '>', 0)])
sin_leer = sum(c.unread_count for c in convers)
if sin_leer:
items.append({
'tipo': 'conversaciones_sin_leer',
'titulo': 'Mensajes sin leer',
'detalle': f'{sin_leer} mensajes no leídos en {len(convers)} conversaciones',
'link': '/wacrm/messages',
'severity': 'info',
})
return json_response({
'status': 'success',
'notifications': items,
'total': len(items),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/horas-agenda', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_horas_agenda(self, **kw):
"""Citas agrupadas por hora del día (8-19) con desglose por estado"""
try:
start = kw.get('start', date.today().strftime('%Y-%m-%d'))
end = kw.get('end', start)
citas = request.env['skeen.cita'].sudo().search([
('date', '>=', start), ('date', '<=', end),
('state', 'not in', ('cancelled', 'no_show')),
])
por_hora = {}
for c in citas:
hora = int(c.time or 0)
entry = por_hora.setdefault(hora, {'total': 0, 'by_state': {}})
entry['total'] += 1
entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1
rows = []
for hora in range(8, 20):
entry = por_hora.get(hora, {'total': 0, 'by_state': {}})
rows.append({'hora': f'{hora:02d}:00', 'total': entry['total'], 'by_state': entry['by_state']})
return json_response({
'status': 'success',
'start': start,
'end': end,
'horas': rows,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/paquetes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_paquetes(self, **kw):
"""Servicios vendidos como paquete: sesiones, ventas y terminados"""
try:
servicios = request.env['skeen.servicio'].sudo().search([
('package_sessions', '>', 0), ('active', '=', True),
], order='name asc')
citas_count = {}
citas_finished = {}
if servicios:
Citas = request.env['skeen.cita'].sudo()
for g in Citas.read_group(
[('servicio_id', 'in', servicios.ids), ('state', '!=', 'cancelled')],
['servicio_id'], ['servicio_id']):
citas_count[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0)
for g in Citas.read_group(
[('servicio_id', 'in', servicios.ids), ('package_finished', '=', True)],
['servicio_id'], ['servicio_id']):
citas_finished[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0)
rows = [{
'id': s.id,
'name': s.name,
'package_sessions': s.package_sessions,
'package_price': s.package_price,
'price': s.price,
'citas': citas_count.get(s.id, 0),
'terminados': citas_finished.get(s.id, 0),
} for s in servicios]
return json_response({'status': 'success', 'paquetes': rows})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/vendedores', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_vendedores(self, **kw):
"""Ventas del periodo agrupadas por usuario que las registró (create_uid)"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
rows = request.env['skeen.venta'].sudo().read_group(
[('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)],
['create_uid', 'total:sum', 'amount_paid:sum'], ['create_uid'])
items = [{
'usuario': r['create_uid'][1] if r['create_uid'] else 'Sin usuario',
'num_ventas': r.get('__count') or r.get('create_uid_count', 0),
'total': r['total'],
'cobrado': r['amount_paid'],
} for r in rows]
items.sort(key=lambda x: -x['total'])
return json_response({
'status': 'success',
'start': start,
'end': end,
'vendedores': items,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/concentrado', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_concentrado(self, **kw):
"""Totales del periodo en una sola respuesta"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
citas = request.env['skeen.cita'].sudo().search([('date', '>=', start), ('date', '<=', end)])
citas_by_state = {}
for c in citas:
citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
ventas = request.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end),
])
pagos = request.env['skeen.pago'].sudo().search([
('payment_date', '>=', f'{start} 00:00:00'),
('payment_date', '<=', f'{end} 23:59:59'),
('state', '=', 'completed'),
])
pagos_by_method = {}
for p in pagos:
pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount
devoluciones = request.env['skeen.venta'].sudo().search([
('refunded', '=', True), ('date', '>=', start), ('date', '<=', end),
])
pacientes_nuevos = request.env['res.partner'].sudo().search_count([
('is_patient', '=', True),
('create_date', '>=', f'{start} 00:00:00'),
('create_date', '<=', f'{end} 23:59:59'),
])
visitas = request.env['skeen.visita'].sudo().search([
('date_start', '>=', f'{start} 00:00:00'),
('date_start', '<=', f'{end} 23:59:59'),
])
visitas_by_state = {}
for v in visitas:
visitas_by_state[v.state] = visitas_by_state.get(v.state, 0) + 1
return json_response({
'status': 'success',
'start': start,
'end': end,
'citas': {'total': len(citas), 'by_state': citas_by_state},
'ventas': {
'count': len(ventas),
'total': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
},
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
'devoluciones': {'count': len(devoluciones), 'total': sum(v.refund_amount for v in devoluciones)},
'pacientes_nuevos': pacientes_nuevos,
'visitas': {'total': len(visitas), 'by_state': visitas_by_state},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/recomendaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_recomendaciones(self, **kw):
"""Pacientes agrupados por quién los recomendó"""
try:
rows = request.env['res.partner'].sudo().read_group(
[('is_patient', '=', True), ('referred_by', 'not in', (False, ''))],
['referred_by'], ['referred_by'])
items = [{
'recomendador': r['referred_by'],
'pacientes': r.get('__count') or r.get('referred_by_count', 0),
} for r in rows if r['referred_by']]
items.sort(key=lambda x: -x['pacientes'])
return json_response({
'status': 'success',
'recomendaciones': items[:200],
'total': sum(i['pacientes'] for i in items),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/monedero-puntos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_monedero_puntos(self, **kw):
"""Puntos del monedero: emitidos, canjeados y saldo activo"""
try:
Tx = request.env['skeen.monedero.transaction'].sudo()
domain = []
if kw.get('start'):
domain.append(('create_date', '>=', f"{kw['start']} 00:00:00"))
if kw.get('end'):
domain.append(('create_date', '<=', f"{kw['end']} 23:59:59"))
by_type = {}
for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
by_type[g['type']] = g['points']
monederos = request.env['skeen.monedero'].sudo().search([])
return json_response({
'status': 'success',
'by_type': by_type,
'puntos_activos': sum(m.points for m in monederos),
'cuentas': len(monederos),
'equivalente_mxn': sum(m.points for m in monederos) * 1.0,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_appointments(self, **kw):
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([('date', '>=', start), ('date', '<=', end)])
by_state = {}
for c in citas:
by_state[c.state] = by_state.get(c.state, 0) + 1
return json_response({
'status': 'success',
'start': start,
'end': end,
'total': len(citas),
'by_state': by_state,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/cash', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_cash(self, **kw):
try:
date_str = kw.get('date', date.today().strftime('%Y-%m-%d'))
# Rango opcional (start/end); si no viene, comportamiento original por día
if kw.get('start') and kw.get('end'):
date_from, date_to = kw['start'], kw['end']
else:
date_from = date_to = date_str
Pagos = request.env['skeen.pago'].sudo()
pagos = Pagos.search([
('payment_date', '>=', f'{date_from} 00:00:00'),
('payment_date', '<=', f'{date_to} 23:59:59'),
('state', '=', 'completed'),
])
by_method = {}
for p in pagos:
by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount
return json_response({
'status': 'success',
'date': date_str,
'total': sum(p.amount for p in pagos),
'by_method': by_method,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/inventory', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_inventory(self, **kw):
"""Reporte de stock: KPIs de valor y niveles + detalle por artículo"""
try:
items = request.env['skeen.inventario.item'].sudo().search([('active', '=', True)], order='name asc')
return json_response({
'status': 'success',
'summary': {
'total_value': sum(i.inventory_value or 0.0 for i in items),
'count': len(items),
'out': sum(1 for i in items if i.stock_level == 'out'),
'critical': sum(1 for i in items if i.stock_level == 'critical'),
},
'items': [self._item_to_dict(i) for i in items],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reminders/pending', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def pending_reminders(self, **kw):
"""Devuelve recordatorios pendientes: citas 24h, follow-ups 7d, re-engagement 90d."""
try:
today = date.today()
tomorrow = today + timedelta(days=1)
seven_days_ago = today - timedelta(days=7)
ninety_days_ago = today - timedelta(days=90)
Citas = request.env['skeen.cita'].sudo()
Pacientes = request.env['res.partner'].sudo()
# Recordatorios 24h antes
citas_manana = Citas.search([
('date', '=', tomorrow),
('state', '=', 'confirmed'),
])
reminders = []
for c in citas_manana:
if c.paciente_id and c.paciente_id.phone:
reminders.append({
'type': 'appointment_reminder',
'phone': c.paciente_id.phone,
'patient_name': c.paciente_id.name,
'appointment_id': c.id,
'reference': c.name,
'service': c.servicio_id.name if c.servicio_id else '',
'date': str(c.date),
'time': c.time,
})
# Follow-ups 7 días después
citas_semana = Citas.search([
('date', '=', seven_days_ago),
('state', '=', 'done'),
])
followups = []
for c in citas_semana:
if c.paciente_id and c.paciente_id.phone:
followups.append({
'type': 'followup',
'phone': c.paciente_id.phone,
'patient_name': c.paciente_id.name,
'appointment_id': c.id,
'service': c.servicio_id.name if c.servicio_id else '',
'date': str(c.date),
})
# Re-engagement 90 días (solo pacientes con last_visit real)
pacientes_inactivos = Pacientes.search([
('is_patient', '=', True),
('last_visit', '!=', False),
('last_visit', '<=', ninety_days_ago),
], limit=50)
reengagements = []
for p in pacientes_inactivos:
if p.phone:
reengagements.append({
'type': 'reengagement',
'phone': p.phone,
'patient_name': p.name,
'last_visit': str(p.last_visit) if p.last_visit else None,
})
return json_response({
'status': 'success',
'reminders': reminders,
'followups': followups,
'reengagements': reengagements,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/top_services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_top_services(self, **kw):
try:
start = kw.get('start', date.today().replace(day=1).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
limit = min(max(int(kw.get('limit', 8) or 8), 1), 50)
ventas = request.env['skeen.venta'].sudo().search([
('date', '>=', start), ('date', '<=', end),
('state', 'in', ['confirmed', 'paid', 'partial']),
])
rows = []
if ventas:
grouped = request.env['skeen.venta.line'].sudo().read_group(
[('venta_id', 'in', ventas.ids)],
['service_id', 'subtotal:sum', 'quantity:sum'],
['service_id'], orderby='subtotal desc', limit=limit,
)
for g in grouped:
rows.append({
'service_id': g['service_id'][0],
'service': g['service_id'][1],
'qty': g.get('quantity', 0),
'revenue': g.get('subtotal', 0.0),
})
return json_response({'status': 'success', 'start': start, 'end': end, 'services': rows})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Tipo de cambio USD/MXN
# ============================================================
@http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_exchange_rate(self, **kw):
try:
TC = request.env['skeen.tipo.cambio'].sudo()
current = TC.search([], order='date desc, id desc', limit=1)
history = TC.search([], order='date desc, id desc', limit=30)
return json_response({
'status': 'success',
'current': {
'rate': current.rate if current else 0.0,
'date': current.date.strftime('%Y-%m-%d') if current else None,
'source': current.source if current else '',
} if current else None,
'history': [{
'id': r.id,
'date': r.date.strftime('%Y-%m-%d'),
'rate': r.rate,
'source': r.source or '',
} for r in history],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('admin')
def set_exchange_rate(self, **kw):
try:
data = _parse_json_body()
rate = float(data.get('rate') or 0)
if rate <= 0:
return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400)
today = fields.Date.today()
TC = request.env['skeen.tipo.cambio'].sudo()
existing = TC.search([('date', '=', today)], limit=1)
vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')}
if existing:
existing.write(vals)
rec = existing
else:
rec = TC.create({'date': today, **vals})
return json_response({
'status': 'success',
'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Meta de ventas (mensual) vs. avance actual
# ============================================================
@http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_sales_goal(self, **kw):
try:
ICP = request.env['ir.config_parameter'].sudo()
goal = float(ICP.get_param('skeen.sales_goal_monthly', 0) or 0)
today = date.today()
start = today.replace(day=1)
ventas = request.env['skeen.venta'].sudo().search([
('date', '>=', start), ('date', '<=', today),
('state', 'in', ['confirmed', 'paid', 'partial']),
])
current = sum(v.total for v in ventas)
pct = (current / goal * 100.0) if goal > 0 else 0.0
return json_response({
'status': 'success',
'goal': goal,
'current': current,
'pct': min(pct, 999.0),
'start': start.strftime('%Y-%m-%d'),
'end': today.strftime('%Y-%m-%d'),
'count': len(ventas),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_sales_goal(self, **kw):
try:
data = _parse_json_body()
goal = float(data.get('goal') or 0)
if goal < 0:
return json_response({'status': 'error', 'message': 'goal debe ser >= 0'}, 400)
request.env['ir.config_parameter'].sudo().set_param('skeen.sales_goal_monthly', goal)
return json_response({'status': 'success', 'goal': goal})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Reporte de cumpleañeros
# ============================================================
@http.route('/skeen/frontend/v1/reports/birthdays', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_birthdays(self, **kw):
try:
import calendar
period = kw.get('period', 'month')
today = date.today()
if period == 'today':
start = end = today
elif period == 'week':
start = today
end = today + timedelta(days=7)
else: # month (mes actual)
start = today.replace(day=1)
end = today.replace(day=calendar.monthrange(today.year, today.month)[1])
pacientes = request.env['res.partner'].sudo().search([
('is_patient', '=', True),
('birth_date', '!=', False),
])
matched = []
for p in pacientes:
b = p.birth_date
cands = []
for yr in (today.year, today.year + 1):
try:
cands.append(b.replace(year=yr))
except ValueError: # 29-feb en año no bisiesto
cands.append(b.replace(year=yr, day=28))
hit = next((o for o in cands if start <= o <= end), None)
if hit:
matched.append((p, hit))
matched.sort(key=lambda t: (t[1].month, t[1].day))
matched = matched[:200]
# Adeudo por paciente en una sola consulta agrupada
ids = [p.id for p, _ in matched]
adeudo_map = {}
if ids:
rows = request.env['skeen.venta'].sudo().read_group(
[('partner_id', 'in', ids), ('state', 'in', ['confirmed', 'paid', 'partial'])],
['partner_id', 'amount_due:sum'],
['partner_id'],
)
for r in rows:
adeudo_map[r['partner_id'][0]] = r['amount_due']
items = [{
'id': p.id,
'name': p.name,
'phone': p.phone or '',
'birth_date': p.birth_date.strftime('%Y-%m-%d'),
'occurs_on': occ.strftime('%Y-%m-%d'),
'turning_age': occ.year - p.birth_date.year,
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
'total_spent': p.total_spent,
'amount_due': adeudo_map.get(p.id, 0.0),
'is_vip': p.is_vip,
} for p, occ in matched]
return json_response({
'status': 'success',
'period': period,
'start': start.strftime('%Y-%m-%d'),
'end': end.strftime('%Y-%m-%d'),
'total': len(items),
'birthdays': items,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Inventario / Consumibles
# ============================================================
def _item_to_dict(self, it):
return {
'id': it.id,
'name': it.name,
'kind': it.kind,
'sku': it.sku or '',
'category': it.category or '',
'unit': it.unit or 'pieza',
'qty': it.qty,
'qty_optimal': it.qty_optimal,
'qty_min': it.qty_min,
'cost': it.cost,
'price': it.price,
'inventory_value': it.inventory_value,
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
'active': it.active,
'genera_monedero': it.genera_monedero,
'notes': it.notes or '',
'stock_level': it.stock_level,
}
def _move_to_dict(self, m):
return {
'id': m.id,
'item_id': m.item_id.id,
'type': m.type,
'qty': m.qty,
'before_qty': m.before_qty,
'after_qty': m.after_qty,
'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None,
'reference': m.reference or '',
'notes': m.notes or '',
}
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def inventory_list(self, **kw):
try:
Item = request.env['skeen.inventario.item'].sudo()
domain = [('active', '=', True)]
if kw.get('kind') in ('producto', 'consumible'):
domain.append(('kind', '=', kw['kind']))
if kw.get('level') in ('out', 'critical', 'low', 'optimal'):
domain.append(('stock_level', '=', kw['level']))
if kw.get('search'):
s = kw['search']
domain += ['|', '|', ('name', 'ilike', s), ('sku', 'ilike', s), ('category', 'ilike', s)]
items = Item.search(domain, order='name asc', limit=500)
# Resumen (mismo filtro de kind, sin level/search)
sum_domain = [('active', '=', True)]
if kw.get('kind') in ('producto', 'consumible'):
sum_domain.append(('kind', '=', kw['kind']))
all_items = Item.search(sum_domain)
levels = {'out': 0, 'critical': 0, 'low': 0, 'optimal': 0}
total_value = 0.0
for a in all_items:
levels[a.stock_level] = levels.get(a.stock_level, 0) + 1
total_value += a.inventory_value or 0.0
return json_response({
'status': 'success',
'items': [self._item_to_dict(i) for i in items],
'summary': {'levels': levels, 'total_value': total_value, 'count': len(all_items)},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def inventory_create(self, **kw):
try:
data = _parse_json_body()
if not data.get('name'):
return json_response({'status': 'error', 'message': 'name es obligatorio'}, 400)
vals = {
'name': data.get('name'),
'kind': data.get('kind', 'producto'),
'sku': data.get('sku', ''),
'category': data.get('category', ''),
'unit': data.get('unit', 'pieza'),
'qty': float(data.get('qty', 0) or 0),
'qty_optimal': float(data.get('qty_optimal', 0) or 0),
'qty_min': float(data.get('qty_min', 0) or 0),
'cost': float(data.get('cost', 0) or 0),
'expiry_date': data.get('expiry_date') or False,
'notes': data.get('notes', ''),
}
item = request.env['skeen.inventario.item'].sudo().create(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
def inventory_update(self, item_id, **kw):
try:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists():
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'expiry_date', 'notes', 'active', 'genera_monedero']
vals = {}
for k in allowed:
if k in data:
vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost'):
if f in vals:
vals[f] = float(vals[f] or 0)
item.write(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def inventory_moves(self, item_id, **kw):
try:
moves = request.env['skeen.inventario.move'].sudo().search([('item_id', '=', item_id)], order='date desc', limit=100)
return json_response({'status': 'success', 'moves': [self._move_to_dict(m) for m in moves]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def inventory_move_create(self, item_id, **kw):
try:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists():
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
mtype = data.get('type')
if mtype not in ('compra', 'venta', 'baja', 'ajuste'):
return json_response({'status': 'error', 'message': 'type inválido'}, 400)
qty = float(data.get('qty', 0) or 0)
if qty < 0:
return json_response({'status': 'error', 'message': 'qty debe ser >= 0'}, 400)
move = request.env['skeen.inventario.move'].sudo().create({
'item_id': item_id,
'type': mtype,
'qty': qty,
'reference': data.get('reference', ''),
'notes': data.get('notes', ''),
})
# refrescar item tras el movimiento
item.invalidate_recordset()
return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/movimientos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def inventario_movimientos(self, **kw):
"""Movimientos de inventario (filtros: tipo, rango de fechas, paginado)"""
try:
domain = []
if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste'):
domain.append(('type', '=', kw['type']))
if kw.get('date_from'):
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
if kw.get('date_to'):
domain.append(('date', '<=', f"{kw['date_to']} 23:59:59"))
if kw.get('search'):
domain.append(('item_id.name', 'ilike', kw['search']))
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Moves = request.env['skeen.inventario.move'].sudo()
total = Moves.search_count(domain)
offset = (page - 1) * page_size
moves = Moves.search(domain, order='date desc, id desc', limit=page_size, offset=offset)
return json_response({
'status': 'success',
'movimientos': [{
**self._move_to_dict(m),
'item': m.item_id.name,
'unit': m.item_id.unit or 'pieza',
} for m in moves],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def inventario_compra(self, **kw):
"""Registrar compra de consumible (sube stock; actualiza costo si viene)"""
try:
data = _parse_json_body()
item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
if not item.exists():
return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
qty = float(data.get('qty', 0) or 0)
if qty <= 0:
return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
if data.get('cost') is not None and data.get('cost') != '':
cost = float(data.get('cost') or 0)
if cost < 0:
return json_response({'status': 'error', 'message': 'El costo no puede ser negativo'}, 400)
item.write({'cost': cost})
move = request.env['skeen.inventario.move'].sudo().create({
'item_id': item.id,
'type': 'compra',
'qty': qty,
'reference': data.get('reference', ''),
'notes': data.get('notes', ''),
})
item.invalidate_recordset()
return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/alertas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def inventario_alertas(self, **kw):
"""Caducidades próximas y sugerencia de compra por bajo stock"""
try:
Item = request.env['skeen.inventario.item'].sudo()
today = date.today()
# Caducidades: todo lo que tiene fecha, ordenado por días restantes
caducidades = []
for it in Item.search([('active', '=', True), ('expiry_date', '!=', False)], order='expiry_date asc'):
dias = (it.expiry_date - today).days
caducidades.append({
'id': it.id,
'name': it.name,
'category': it.category or '',
'qty': it.qty,
'unit': it.unit or 'pieza',
'expiry_date': it.expiry_date.strftime('%Y-%m-%d'),
'dias_restantes': dias,
'vencido': dias < 0,
})
# Sugerencia de compra: bajo mínimo (o sin mínimo y sin existencia)
sugerencia = []
for it in Item.search([('active', '=', True)], order='name asc'):
qty = it.qty or 0.0
qty_min = it.qty_min or 0.0
bajo = (qty < qty_min) or (qty_min == 0 and qty == 0)
if not bajo:
continue
optimal = it.qty_optimal or 0.0
if optimal > qty:
sugerido = optimal - qty
else:
sugerido = qty_min * 2
if sugerido <= 0:
sugerido = max(1.0, qty_min)
sugerencia.append({
'id': it.id,
'name': it.name,
'category': it.category or '',
'unit': it.unit or 'pieza',
'qty': qty,
'qty_min': qty_min,
'qty_optimal': optimal,
'sugerido': sugerido,
'costo_estimado': round(sugerido * (it.cost or 0.0), 2),
})
return json_response({
'status': 'success',
'caducidades': caducidades,
'sugerencia_compra': sugerencia,
'sugerencia_total': {
'items': len(sugerencia),
'costo_total': round(sum(s['costo_estimado'] for s in sugerencia), 2),
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def inventario_baja(self, **kw):
"""Registrar baja/merma de consumible (baja stock)"""
try:
data = _parse_json_body()
item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0))
if not item.exists():
return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404)
qty = float(data.get('qty', 0) or 0)
if qty <= 0:
return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400)
move = request.env['skeen.inventario.move'].sudo().create({
'item_id': item.id,
'type': 'baja',
'qty': qty,
'reference': data.get('reference', ''),
'notes': data.get('notes', ''),
})
item.invalidate_recordset()
return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Reporte de comisiones (venta de artículos recetados por médico)
# ============================================================
@http.route('/skeen/frontend/v1/reports/commissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def report_commissions(self, **kw):
try:
vdomain = [('state', 'in', ['confirmed', 'paid', 'partial'])]
if kw.get('start'):
vdomain.append(('date', '>=', kw['start']))
if kw.get('end'):
vdomain.append(('date', '<=', kw['end']))
ventas = request.env['skeen.venta'].sudo().search(vdomain)
venta_ids = ventas.ids
rows = []
total_base = 0.0
total_commission = 0.0
if venta_ids:
line_domain = [('venta_id', 'in', venta_ids), ('is_prescribed', '=', True), ('prescribed_by_id', '!=', False)]
grouped = request.env['skeen.venta.line'].sudo().read_group(
line_domain,
['prescribed_by_id', 'subtotal:sum'],
['prescribed_by_id'],
)
# Ventas distintas por médico
ventas_por_doc = {}
for r in request.env['skeen.venta.line'].sudo().search_read(line_domain, ['prescribed_by_id', 'venta_id']):
did = r['prescribed_by_id'][0]
ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0])
Emp = request.env['hr.employee'].sudo()
for g in grouped:
did = g['prescribed_by_id'][0]
emp = Emp.browse(did)
base = g['subtotal'] or 0.0
pct = emp.commission_pct or 0.0
commission = base * pct / 100.0
total_base += base
total_commission += commission
rows.append({
'doctor_id': emp.id,
'doctor': emp.name,
'job_title': emp.job_title or '',
'commission_pct': pct,
'base': base,
'commission': commission,
'items': g.get('prescribed_by_id_count', 0),
'sales': len(ventas_por_doc.get(did, set())),
})
rows.sort(key=lambda r: r['commission'], reverse=True)
return json_response({
'status': 'success',
'start': kw.get('start'),
'end': kw.get('end'),
'total_base': total_base,
'total_commission': total_commission,
'commissions': rows,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# Importar fields para fechas
from odoo import fields