Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/frontend.py
Kimi d0eac00a40 Agenda: cierre rápido de agendas por médico, lista de espera, duraciones 30/45/60, checklist de estados con bitácora y auto-refresh 30s
- BloqueosModal: alta multi-médico, rango repetible por día de la semana,
  motivos predefinidos, edición/borrado y cierre rápido desde la cabecera
  de cada columna (todo el día / resto del día / rango).
- skeen.lista.espera + panel y modal en la agenda: prioridad, franja, rango
  de fechas, agendar precargado, avisar por WhatsApp y aviso cuando se
  libera un hueco compatible.
- duration en skeen.cita (0 = la del servicio): la cita ocupa sus bloques de
  15 min reales en las 4 vistas, con celdas de continuación.
- Checklist de estados (Agendada → Confirmada → En cabina → En consulta →
  Completada) con avance/retroceso, cancelación y no-show con confirmación,
  y bitácora de quién/cuándo en skeen.cita.state.log.
- Etiquetas del sistema legacy alineadas (Canceló / Lista de espera / En
  cabina) en badges de agenda y dashboard.
- Auto refresco silencioso cada 30 s (pausable, se pospone con modales,
  escritura o drag) en citas + bloqueos + lista de espera en un solo request.
2026-09-24 15:46:38 +00:00

6259 lines
314 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.exceptions import ValidationError
from odoo.http import request, Response
import base64
import json
import re
import uuid
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, forbid_roles, 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',
}
)
# Orden del checklist de atención (espejo de STATE_FLOW en skeen.cita):
# Agendada -> Confirmada -> En cabina -> En consulta -> Completada
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
# Estados que liberan el horario (no ocupan agenda)
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
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 {}
def _patient_search_domain(search):
"""Dominio de búsqueda de pacientes por texto libre.
Se parte la consulta en tokens y cada token debe aparecer en nombre,
teléfono o email (AND implícito entre grupos OR). Así "juan arevalo"
encuentra a "AREVALO LEAL JUAN ENRIQUE" sin importar el orden ni que el
nombre esté capturado con apellidos primero. ilike de Odoo aplica
unaccent, por lo que acentos y mayúsculas son indistintos.
"""
tokens = [t for t in re.split(r'[\s,]+', search or '') if t]
domain = []
for t in tokens:
domain += ['|', '|', ('name', 'ilike', t), ('phone', 'ilike', t), ('email', 'ilike', t)]
return domain
class SkeenFrontendController(http.Controller):
"""Controladores API públicos para el Frontend React de SKEEN"""
# Horario de agenda de la clínica (hora de inicio permitida de una cita)
AGENDA_START = 8.0 # 8:00 AM
AGENDA_END = 19.0 # 7:00 PM
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
EXPORT_MAX = 100000
def _valid_agenda_time(self, value):
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
try:
t = float(value)
except (TypeError, ValueError):
return False
return self.AGENDA_START <= t < self.AGENDA_END
# ------------------------------------------------------------------
# Auditoría de configuración
# ------------------------------------------------------------------
def _fu_user(self):
"""Usuario del frontend que hizo la petición (o None)."""
return getattr(request, 'frontend_user', None)
def _puede_gestionar_usuarios(self):
"""Admin del sistema o gerencia: gestión de usuarios y roles.
El rango de roles no distingue gerencia de recepción (ambos 50), así que
el permiso se comprueba por rol explícito, como en el resto del API.
"""
u = self._fu_user()
return bool(u and u.role in ('admin', 'gerencia'))
def _puede_gestionar_roles(self):
"""Editar la matriz de permisos: admin o gerencia."""
u = self._fu_user()
return bool(u and u.role in ('admin', 'gerencia'))
def _puede_asignar_admin(self):
"""Repartir el rol Administrador queda reservado al propio admin.
Gerencia gestiona usuarios y la matriz, pero no puede crear más admins.
"""
u = self._fu_user()
return bool(u and u.role == 'admin')
def _auditar(self, module, entity, field, old_value=None, new_value=None,
entity_label='', notes=''):
"""Registra un cambio de configuración en skeen.config.audit.
Nunca debe tumbar la operación principal: si la bitácora falla, se
ignora el error y el cambio de configuración sigue adelante.
"""
try:
request.env['skeen.config.audit'].sudo().log_change(
module=module, entity=entity, field=field,
old_value=old_value, new_value=new_value,
entity_label=entity_label, notes=notes, user=self._fu_user())
except Exception:
import logging
logging.getLogger(__name__).exception('No se pudo registrar la auditoría')
def _fmt_horas(self, h):
h = float(h or 0)
return '%d:%02d' % (int(h), int(round((h - int(h)) * 60)))
def _linked_employee(self, user):
"""hr.employee vinculado a un usuario frontend (rol medico).
Vía principal: res.users con el mismo login -> employee_ids.
Fallback: empleado cuyo work_email coincide con el login o cuyo
nombre coincide con el nombre del usuario frontend.
"""
Emp = request.env['hr.employee'].sudo()
ru = request.env['res.users'].sudo().search([('login', '=', user.login)], limit=1)
emp = ru.employee_ids[:1] if ru else Emp.browse()
if not emp and user.login:
emp = Emp.search([('work_email', '=ilike', user.login)], limit=1)
if not emp and user.name:
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
return emp
def _frontend_res_user(self):
"""res.users equivalente al usuario del frontend que hace la petición.
Los controladores con auth='none' crean registros sin uid de Odoo; para
que el usuario quede atribuido (p. ej. `user_id` del corte de caja) se
busca el res.users con el mismo login y, si no existe, se usa el admin.
"""
login = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
User = request.env['res.users'].sudo()
if login:
u = User.search([('login', '=', login)], limit=1)
if u:
return u
return request.env.ref('base.user_admin', raise_if_not_found=False) or User.browse()
def _frontend_user_dict(self, user):
"""to_public_dict + datos del empleado vinculado.
Aplica a los roles clínicos (médico y cosmetología): la agenda usa
employee_id para preseleccionar la columna propia.
"""
d = user.to_public_dict()
if user.role in ('medico', 'cosmetologia'):
emp = self._linked_employee(user)
d['employee_id'] = emp.id if emp else None
d['employee_name'] = emp.name if emp else None
return d
# ============================================================
# 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()
# Login insensible a mayúsculas/minúsculas: los logins guardados
# están en minúsculas, se busca la forma exacta en minúsculas.
user = User.search([('login', '=', login.lower()), ('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': self._frontend_user_dict(user)})
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': self._frontend_user_dict(request.frontend_user)})
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('recepcion')
def auth_users_list(self, **kw):
try:
if not self._puede_gestionar_usuarios():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
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('recepcion')
def auth_users_create(self, **kw):
try:
if not self._puede_gestionar_usuarios():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
login = (data.get('login') or '').strip().lower()
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)
if role == 'admin' and not self._puede_asignar_admin():
return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403)
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)
self._auditar('usuarios', f'usuario:{user.id}', 'alta', '', f'{login} ({name})',
entity_label=name, notes=f'Rol {role}')
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('recepcion')
def auth_users_update(self, user_id, **kw):
try:
if not self._puede_gestionar_usuarios():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
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:
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error', 'message': 'El nombre no puede estar vacío'}, 400)
vals['name'] = name
if 'login' in data:
login = (data.get('login') or '').strip().lower()
if not login:
return json_response({'status': 'error', 'message': 'El usuario no puede estar vacío'}, 400)
if not re.match(r'^[a-z0-9._-]{3,}$', login):
return json_response({
'status': 'error',
'message': 'El usuario debe tener al menos 3 caracteres (letras, números, punto, guion o guion bajo)',
}, 400)
duplicado = request.env['skeen.frontend.user'].sudo().search(
[('login', '=', login), ('id', '!=', user_id)], limit=1)
if duplicado:
return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409)
vals['login'] = login
if 'role' in data:
if data['role'] not in ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403)
if data['role'] == 'admin' and not self._puede_asignar_admin():
return json_response({'status': 'error', 'message': 'Solo un administrador puede asignar el rol Administrador'}, 403)
if user.role == 'admin' and data['role'] != 'admin' and not self._puede_asignar_admin():
return json_response({'status': 'error', 'message': 'Solo un administrador puede quitar el rol Administrador'}, 403)
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:
previos = {k: getattr(user, k) for k in vals}
user.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
self._auditar('usuarios', f'usuario:{user.id}', campo, anterior, nuevo,
entity_label=user.name or user.login)
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'])
self._auditar('usuarios', f'usuario:{user.id}', 'password', '(oculto)', '(cambiada)',
entity_label=user.name or user.login)
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('recepcion')
def auth_users_delete(self, user_id, **kw):
try:
if not self._puede_gestionar_usuarios():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
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)
etiqueta = f'{user.login} ({user.name})'
user.unlink()
self._auditar('usuarios', f'usuario:{user_id}', 'baja', etiqueta, '',
entity_label=etiqueta, notes='Usuario eliminado')
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('recepcion')
def roles_permissions_put(self, **kw):
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
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)
ICP = request.env['ir.config_parameter'].sudo()
anterior = ICP.get_param(MENU_ROLES_KEY, '') or ''
ICP.set_param(MENU_ROLES_KEY, json.dumps(clean))
self._auditar('roles', 'menu_roles', 'permisos',
(anterior[:2000] or '(por defecto)'), json.dumps(clean)[:2000],
entity_label='Matriz de menús por rol',
notes=f'{len(clean)} rutas')
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'))])
# Comparativas contra el período anterior equivalente (deltas):
# - Ingresos Mes: mes anterior recortado al mismo día de corte (MTD)
# - Ventas Hoy: mismo conteo del día de ayer
prev_month_last = first_day - timedelta(days=1)
prev_month_first = prev_month_last.replace(day=1)
prev_month_end = prev_month_first.replace(day=min(today.day, prev_month_last.day))
prev_revenue_month = sum(v.total for v in Ventas.search([
('date', '>=', prev_month_first),
('date', '<=', prev_month_end),
('state', 'in', ('paid', 'partial')),
]))
prev_sales_today = Ventas.search_count([
('date', '=', today - timedelta(days=1)),
('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,
'prev_revenue_month': prev_revenue_month,
'prev_sales_today': prev_sales_today,
}
})
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)
Rol 'medico': solo ve las citas asignadas a su hr.employee vinculado
(filtro del lado del servidor, ignora cualquier doctor_id recibido).
"""
try:
user = request.frontend_user
if user and user.role == 'medico':
emp = self._linked_employee(user)
if not emp:
# Sin empleado vinculado: agenda vacía (el frontend muestra aviso)
return json_response({
'status': 'success',
'appointments': [],
'bloqueos': [],
'total': 0,
'page': 1,
'page_size': int(kw.get('page_size', 50) or 50),
'total_pages': 0,
'medico_sin_employee': True,
})
domain = [('doctor_id', '=', emp.id)]
else:
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') and not (user and user.role == 'medico'):
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)]
# pending_confirm=1: citas activas SIN 1ra confirmación telefónica
if kw.get('pending_confirm') in ('1', 'true', 'True'):
domain.append(('phone_confirmed', '=', False))
domain.append(('state', 'not in', INACTIVE_STATES + ('done',)))
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),
# La lista de espera viaja en la misma respuesta para que el
# auto refresco de la agenda sea un solo request por ciclo.
'lista_espera': [
self._lista_espera_to_dict(r)
for r in request.env['skeen.lista.espera'].sudo().search(
[('state', '=', 'esperando')],
order='priority_rank asc, create_date asc', limit=200)
],
'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()
# Acepta ambos estilos de clave (el frontend usa patient_id/service_id)
partner_id = data.get('partner_id', data.get('patient_id'))
servicio_id = data.get('servicio_id', data.get('service_id'))
if not partner_id or not servicio_id:
return json_response({'status': 'error', 'message': 'Paciente y servicio son obligatorios'}, 400)
servicio = request.env['skeen.servicio'].sudo().browse(int(servicio_id))
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': partner_id,
'servicio_id': 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'),
'duration': int(data.get('duration') or 0),
}
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
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 ValidationError as e:
# Conflicto de agenda (solape o bloqueo): responder 409 con la cita
# en conflicto cuando se pueda identificar.
request.env.cr.rollback()
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload({
'date': data.get('date'),
'time': data.get('time'),
'doctor_id': data.get('doctor_id'),
'servicio_id': servicio_id,
'state': data.get('state', 'pending'),
}))
return json_response(resp, 409)
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/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_quick_appointment(self, **kw):
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
Busca el paciente por teléfono y lo reutiliza si existe; si no, lo crea
con datos mínimos. El servicio usa uno por defecto (primera 'consulta'
activa, o el primer servicio activo) salvo que se indique servicio_id.
"""
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
phone = (data.get('phone') or '').strip()
if not name or not phone:
return json_response({'status': 'error', 'message': 'Nombre y teléfono son obligatorios'}, 400)
apt_date = data.get('date') or date.today().strftime('%Y-%m-%d')
apt_time = data.get('time')
if apt_time is None or not self._valid_agenda_time(apt_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
Partner = request.env['res.partner'].sudo()
digits = re.sub(r'\D', '', phone)
patient = None
if len(digits) >= 7:
# Comparación sobre dígitos normalizados (ignora espacios, +52, etc.):
# coinciden si los últimos N dígitos (N = longitud del más corto) son iguales
match_id = None
candidates = Partner.search_read(
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
['phone', 'mobile'], limit=10000)
for c in candidates:
for f in ('phone', 'mobile'):
c_digits = re.sub(r'\D', '', c.get(f) or '')
n = min(len(c_digits), len(digits))
if n >= 7 and c_digits[-n:] == digits[-n:]:
match_id = c['id']
break
if match_id:
break
if match_id:
patient = Partner.browse(match_id)
if not patient:
patient = Partner.search([
('is_patient', '=', True), ('phone', '=', phone),
], limit=1)
patient_created = False
if not patient:
patient = Partner.create({
'name': name,
'phone': phone,
'is_patient': True,
'source': data.get('source', 'walkin'),
})
patient_created = True
Servicio = request.env['skeen.servicio'].sudo()
servicio = Servicio.browse(int(data.get('servicio_id') or 0))
if not servicio.exists():
servicio = Servicio.search([('category', '=', 'consulta'), ('active', '=', True)],
order='sequence, id', limit=1)
if not servicio:
servicio = Servicio.search([('active', '=', True)], order='sequence, id', limit=1)
if not servicio:
return json_response({'status': 'error', 'message': 'No hay servicios activos para agendar'}, 400)
cita = request.env['skeen.cita'].sudo().create({
'partner_id': patient.id,
'servicio_id': servicio.id,
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'date': apt_date,
'time': float(apt_time),
'branch': data.get('branch', 'rosarito'),
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
})
return json_response({
'status': 'success',
'appointment': self._appointment_to_dict(cita),
'patient': self._patient_to_dict(patient),
'patient_created': patient_created,
}, 201)
except ValidationError as e:
request.env.cr.rollback()
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload({
'date': data.get('date'),
'time': data.get('time'),
'doctor_id': data.get('doctor_id'),
'servicio_id': data.get('servicio_id'),
'state': data.get('state', 'pending'),
}))
return json_response(resp, 409)
except Exception as e:
# Rollback explícito: evita persistir paciente/cita con 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=['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', 'duration']
vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
try:
cita.write(vals)
except ValidationError as e:
# Conflicto de agenda: valores efectivos = cita actual + cambios
request.env.cr.rollback()
effective = {
'date': vals.get('date') or (cita.date.strftime('%Y-%m-%d') if cita.date else None),
'time': vals.get('time', cita.time),
'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False),
'servicio_id': vals.get('servicio_id', cita.servicio_id.id),
'duration': vals.get('duration', cita.duration),
'state': vals.get('state', cita.state),
}
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload(effective, exclude_id=cita.id))
return json_response(resp, 409)
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):
"""Cambio de estado de la cita (checklist con retroceso).
Acciones:
confirm | arrive | start | done | cancel | no_show (flujo clásico)
next (avanza un paso)
back (retrocede un paso)
set_state + {state: '...'} (salto directo)
Todo cambio queda en la bitácora con el usuario y la hora.
"""
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')
user = request.frontend_user.login
note = (data.get('reason') or '').strip()
if action == 'confirm':
cita.set_state('confirmed', user=user)
elif action == 'arrive':
cita.set_state('arrived', user=user)
elif action == 'start':
cita.set_state('in_progress', user=user)
elif action == 'done':
cita.set_state('done', user=user)
elif action == 'cancel':
cita.set_state('cancelled', user=user, note=note)
if note:
cita.write({'cancellation_reason': note})
elif action == 'no_show':
cita.set_state('no_show', user=user, note=note)
elif action == 'waitlist':
cita.set_state('waitlist', user=user, note=note)
elif action == 'next':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx < 0 or idx >= len(STATE_FLOW) - 1:
return json_response(
{'status': 'error', 'message': 'No hay un paso siguiente desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx + 1], user=user)
elif action == 'back':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx <= 0:
return json_response(
{'status': 'error', 'message': 'No hay un paso anterior desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx - 1], user=user, note='Retroceso de estado')
elif action == 'set_state':
target = data.get('state')
if target not in STATE_FLOW + ['cancelled', 'no_show', 'waitlist']:
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
cita.set_state(target, user=user, note='Cambio manual de estado')
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 ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/state_history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def appointment_state_history(self, appointment_id, **kw):
"""Bitácora de cambios de estado de la cita (quién, cuándo y de qué a qué)."""
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
logs = request.env['skeen.cita.state.log'].sudo().search(
[('cita_id', '=', cita.id)], order='create_date desc, id desc', limit=100)
selection = dict(cita._fields['state'].selection)
return json_response({
'status': 'success',
'history': [{
'id': l.id,
'from_state': l.from_state,
'from_label': selection.get(l.from_state, l.from_state or ''),
'to_state': l.to_state,
'to_label': selection.get(l.to_state, l.to_state or ''),
'user': l.user_name or '',
'note': l.note or '',
'date': l.date_change.strftime('%Y-%m-%d %H:%M:%S') if l.date_change else '',
} for l in logs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def appointment_phone_confirm(self, appointment_id, **kw):
"""Toggle de la 1ra confirmación (telefónica) de una cita.
Body: {"confirmed": true|false}. Si no se envía, invierte el valor actual.
Registra quién (login del usuario frontend) y cuándo confirmó.
Permiso: recepción+ (mismo nivel que check-in/no-show; el rol medico
tiene el mismo rango y también puede confirmar).
"""
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()
confirmed = bool(data.get('confirmed', not cita.phone_confirmed))
if confirmed:
cita.write({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': request.frontend_user.login,
})
else:
cita.write({
'phone_confirmed': False,
'phone_confirmed_at': False,
'phone_confirmed_by': False,
})
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
request.env.cr.rollback()
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/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def bulk_phone_confirm(self, **kw):
"""Confirmación telefónica en lote.
Body: {"ids": [..], "confirmed": true|false}. Valida que todas las
citas existan y no estén canceladas / no_show / done; confirma el resto
y devuelve conteos. Permiso: recepción+ (igual que phone_confirm).
"""
try:
data = _parse_json_body()
raw_ids = data.get('ids') or []
confirmed = bool(data.get('confirmed', True))
if not isinstance(raw_ids, list) or not raw_ids:
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
try:
ids = [int(i) for i in raw_ids]
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
Citas = request.env['skeen.cita'].sudo()
citas = Citas.browse(ids).exists()
found = set(citas.ids)
not_found = [i for i in dict.fromkeys(ids) if i not in found]
elegibles = citas.filtered(lambda c: c.state not in INACTIVE_STATES + ('done',))
skipped_invalid_state = [c.id for c in citas if c.state in INACTIVE_STATES + ('done',)]
if confirmed:
elegibles.write({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': request.frontend_user.login,
})
else:
elegibles.write({'phone_confirmed': False, 'phone_confirmed_at': False, 'phone_confirmed_by': False})
return json_response({
'status': 'success',
'confirmed_count': len(elegibles),
'skipped_invalid_state': skipped_invalid_state,
'not_found': not_found,
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _agenda_conflict_payload(self, vals, exclude_id=None):
"""Detecta el origen del conflicto de agenda para los valores intentados.
Devuelve {'conflicting_appointment': ...} cuando hay solapamiento con
otra cita, o {} si no se pudo identificar. Replica la lógica de
_check_availability de skeen.cita, incluida la duración real de la cita
(duración propia o, si es 0, la del servicio).
"""
try:
date_str = vals.get('date')
time_f = float(vals.get('time'))
state = vals.get('state') or 'pending'
if not date_str or state in ('cancelled', 'no_show', 'waitlist'):
return {}
servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0))
dur = int(vals.get('duration') or 0) or (
servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
hh, mm = int(time_f), int(round((float(time_f) % 1) * 60))
start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M')
end_dt = start_dt + timedelta(minutes=dur)
doctor_id = int(vals.get('doctor_id') or 0) or False
domain = [
('id', '!=', exclude_id),
('date', '=', date_str),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('doctor_id', '=', doctor_id),
('datetime_start', '<', end_dt),
('datetime_end', '>', start_dt),
]
other = request.env['skeen.cita'].sudo().search(domain, order='time asc', limit=1)
if not other:
return {}
return {'conflicting_appointment': {
'id': other.id,
'time': other.time_str,
'patient': other.partner_id.name,
'doctor': other.doctor_id.name if other.doctor_id else 'Sin médico',
}}
except Exception:
return {}
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw):
"""Slots libres (cada 15 min) para una fecha y servicio.
Acepta `duration` para simular una duración distinta a la del servicio
(la agenda deja que recepción la ajuste). El hueco se descarta si el
rango completo inicio+duración choca con otra cita o con un bloqueo.
"""
try:
date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0))
doctor_id = int(kw.get('doctor_id', 0)) if kw.get('doctor_id') else None
duration = int(kw.get('duration', 0) or 0) or None
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, doctor_id=doctor_id, duration=duration)
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_tipo': b.motivo_tipo or 'otro',
'motivo_tipo_label': dict(b._fields['motivo_tipo'].selection).get(b.motivo_tipo or '', ''),
'motivo': b.motivo or '',
'motivo_label': b.nombre_motivo(),
'grupo': b.grupo or '',
'created_by': b.created_by 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'))))
if kw.get('grupo'):
domain.append(('grupo', '=', kw['grupo']))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc, time_from', 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):
"""Crea uno o varios bloqueos de agenda.
Body:
doctor_ids: [1, 2] (o `doctor_id` simple) — multi-selección
date: 'YYYY-MM-DD' (o date_from/date_to para un rango)
days: [0, 2] (opcional) repetir solo esos días de la semana
dentro del rango, p. ej. "todos los lunes"
all_day: bool
time_from / time_to: float de horas (si no es todo el día)
motivo_tipo: vacaciones|junta|comida|personal|otro
motivo: texto libre
force: true permite bloquear aunque haya citas agendadas
Todos los registros creados comparten un `grupo` para poder borrarlos
o localizarlos como serie.
"""
try:
data = _parse_json_body()
raw_doctors = data.get('doctor_ids') or ([data.get('doctor_id')] if data.get('doctor_id') else [])
doctor_ids = []
for d in raw_doctors:
try:
doctor_ids.append(int(d))
except (TypeError, ValueError):
continue
doctor_ids = list(dict.fromkeys(doctor_ids))
if not doctor_ids:
return json_response({'status': 'error', 'message': 'Selecciona al menos un médico'}, 400)
doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
if not date_from:
return json_response({'status': 'error', 'message': 'La fecha es obligatoria'}, 400)
try:
ini = datetime.strptime(str(date_from)[:10], '%Y-%m-%d').date()
fin = datetime.strptime(str(date_to)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if fin < ini:
return json_response({'status': 'error', 'message': 'El rango de fechas está invertido'}, 400)
if (fin - ini).days > 366:
return json_response({'status': 'error', 'message': 'El rango no puede exceder un año'}, 400)
days = data.get('days') or []
try:
days = sorted({int(d) for d in days})
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Días inválidos'}, 400)
all_day = bool(data.get('all_day'))
try:
time_from = float(data.get('time_from', 9.0) or 9.0)
time_to = float(data.get('time_to', 10.0) or 10.0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
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)
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
return json_response(
{'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400)
motivo_tipo = data.get('motivo_tipo') or 'otro'
if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
motivo_tipo = 'otro'
motivo = (data.get('motivo') or '').strip()
fechas = []
cur = ini
while cur <= fin:
if not days or cur.weekday() in days:
fechas.append(cur)
cur += timedelta(days=1)
if not fechas:
return json_response(
{'status': 'error', 'message': 'Ninguna fecha del rango cae en los días seleccionados'}, 400)
grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse()
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
'motivo_tipo': motivo_tipo,
'motivo': motivo,
'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
return json_response({
'status': 'success',
'grupo': grupo,
'creados': len(creados),
'bloqueos': [self._bloqueo_to_dict(b) for b in creados],
}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_bloqueo(self, bloqueo_id, **kw):
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'doctor_id' in data and data['doctor_id']:
vals['doctor_id'] = int(data['doctor_id'])
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
vals['all_day'] = bool(data['all_day'])
if 'time_from' in data and data['time_from'] is not None:
vals['time_from'] = float(data['time_from'])
if 'time_to' in data and data['time_to'] is not None:
vals['time_to'] = float(data['time_to'])
if 'motivo_tipo' in data:
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).write(vals)
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e:
request.env.cr.rollback()
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)
etiqueta = '%s · %s' % (bloqueo.doctor_id.name, bloqueo.date)
bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_bloqueo_grupo(self, grupo, **kw):
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
try:
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
bloqueos = Bloqueo.search([('grupo', '=', grupo)])
if not bloqueos:
return json_response({'status': 'error', 'message': 'Serie no encontrada'}, 404)
n = len(bloqueos)
bloqueos.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar-serie',
entity_label=grupo, old_value='%s bloqueo(s)' % n)
return json_response({'status': 'success', 'eliminados': n})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
'reference': r.name,
'patient_id': r.partner_id.id,
'patient': r.partner_id.name,
'phone': r.partner_id.phone or r.partner_id.mobile or '',
'doctor_id': r.doctor_id.id if r.doctor_id else None,
'doctor': r.doctor_id.name if r.doctor_id else None,
'service_id': r.servicio_id.id if r.servicio_id else None,
'service': r.servicio_id.name if r.servicio_id else None,
'priority': r.priority,
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
'state': r.state,
'cita_id': r.cita_id.id if r.cita_id else None,
'created_by': r.created_by or '',
'create_date': r.create_date.strftime('%Y-%m-%d %H:%M') if r.create_date else None,
'resolved_at': r.resolved_at.strftime('%Y-%m-%d %H:%M') if r.resolved_at else None,
'espera_dias': max(0, (date.today() - r.create_date.date()).days) if r.create_date else 0,
}
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_lista_espera(self, **kw):
"""Pacientes en lista de espera (ordenados por prioridad y antigüedad)."""
try:
domain = []
state = kw.get('state')
domain.append(('state', '=', state if state else 'esperando'))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
limit = min(max(int(kw.get('limit', 200) or 200), 1), 500)
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=limit)
return json_response({
'status': 'success',
'lista': [self._lista_espera_to_dict(r) for r in records],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/compatibles', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def lista_espera_compatibles(self, **kw):
"""Pacientes en espera compatibles con un hueco que se acaba de liberar.
Filtra por médico (o sin médico = cualquiera) y por el rango de fechas
deseado. Marca `match_servicio` cuando además coincide el servicio, para
que la UI pueda priorizar el aviso.
"""
try:
doctor_id = int(kw.get('doctor_id')) if kw.get('doctor_id') else None
servicio_id = int(kw.get('servicio_id')) if kw.get('servicio_id') else None
fecha = kw.get('date')
domain = [('state', '=', 'esperando')]
if doctor_id:
domain += ['|', ('doctor_id', '=', doctor_id), ('doctor_id', '=', False)]
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=50)
if fecha:
records = records.filtered(lambda r: (
(not r.date_from or r.date_from.strftime('%Y-%m-%d') <= fecha)
and (not r.date_to or r.date_to.strftime('%Y-%m-%d') >= fecha)
))
out = []
for r in records:
d = self._lista_espera_to_dict(r)
d['match_servicio'] = bool(servicio_id and r.servicio_id and r.servicio_id.id == servicio_id)
d['match_doctor'] = bool(doctor_id and r.doctor_id and r.doctor_id.id == doctor_id)
out.append(d)
out.sort(key=lambda d: (0 if d['match_doctor'] else 1, 0 if d['match_servicio'] else 1))
return json_response({'status': 'success', 'lista': out, 'total': len(out)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_lista_espera(self, **kw):
try:
data = _parse_json_body()
partner_id = data.get('partner_id') or data.get('patient_id')
if not partner_id:
return json_response({'status': 'error', 'message': 'El paciente es obligatorio'}, 400)
if data.get('priority') and data['priority'] not in ('alta', 'normal', 'baja'):
return json_response({'status': 'error', 'message': 'Prioridad no válida'}, 400)
record = request.env['skeen.lista.espera'].sudo().create({
'partner_id': int(partner_id),
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
'created_by': request.frontend_user.login,
})
self._auditar('agenda', 'skeen.lista.espera', 'crear',
entity_label=record.partner_id.name,
new_value=record.name)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_lista_espera(self, registro_id, **kw):
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
data = _parse_json_body()
if data.get('state') not in (None, '', 'esperando', 'agendado', 'descartado'):
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
vals = {}
for key, field in (
('doctor_id', 'doctor_id'), ('service_id', 'servicio_id'),
('servicio_id', 'servicio_id'), ('priority', 'priority'),
('franja', 'franja'), ('date_from', 'date_from'), ('date_to', 'date_to'),
('notes', 'notes'), ('partner_id', 'partner_id'),
):
if key in data:
vals[field] = data[key] or False
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
new_state = data.get('state')
if new_state:
vals['state'] = new_state
if new_state in ('agendado', 'descartado'):
vals['resolved_at'] = fields.Datetime.now()
else:
vals['resolved_at'] = False
if vals:
record.write(vals)
if new_state:
etiquetas = {'agendado': 'Agendado', 'descartado': 'Descartado', 'esperando': 'Reabierto'}
record._log(etiquetas.get(new_state, new_state), user=request.frontend_user.login)
self._auditar('agenda', 'skeen.lista.espera', 'estado',
entity_label=record.partner_id.name, new_value=new_state)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_lista_espera(self, registro_id, **kw):
try:
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
etiqueta = '%s · %s' % (record.name, record.partner_id.name)
record.unlink()
self._auditar('agenda', 'skeen.lista.espera', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Registro 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,
'time_float': cita.time,
'state': cita.state,
'state_label': dict(cita._fields['state'].selection).get(cita.state, cita.state),
'payment_state': cita.payment_state,
'price': cita.servicio_price,
'amount_paid': cita.amount_paid,
# Duración efectiva (propia de la cita o la del servicio)
'duration': cita._duracion(),
'duration_override': cita.duration or 0,
'service_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,
# 1ra confirmación (teléfono). La 2da (llegada) se refleja en state.
'phone_confirmed': cita.phone_confirmed,
'phone_confirmed_at': cita.phone_confirmed_at.strftime('%Y-%m-%d %H:%M') if cita.phone_confirmed_at else None,
'phone_confirmed_by': cita.phone_confirmed_by or None,
# 2da confirmación: timestamp del check-in (base del tiempo de espera)
'arrived_at': cita.arrived_at.strftime('%Y-%m-%d %H:%M:%S') if cita.arrived_at else None,
# Cumpleaños del paciente el día de la cita (para badge pastel en agenda)
'patient_birthday': bool(
cita.date and cita.partner_id.birth_date
and (cita.date.month, cita.date.day) == (cita.partner_id.birth_date.month, cita.partner_id.birth_date.day)
),
}
# ============================================================
# 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'):
domain += _patient_search_domain(kw.get('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))
elif kw.get('recent') == '0':
# Sin visita en los últimos 6 meses (incluye sin visita registrada)
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain += ['|', ('last_visit', '=', False), ('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/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def export_patients(self, **kw):
"""Todos los pacientes activos que cumplen los filtros, SIN paginar.
Pensado para exportaciones (marketing y quien tenga permiso de lectura):
devuelve ~7k filas de una sola vez (~3 MB de JSON), que es manejable.
`limit` sólo existe como salvaguarda ante un catálogo runaway; por
defecto no hay tope práctico (EXPORT_MAX).
"""
try:
domain = [('is_patient', '=', True)]
if kw.get('search'):
domain += _patient_search_domain(kw['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['doctor_id'])))
if kw.get('recent') == '1':
seis = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain.append(('last_visit', '>=', seis))
elif kw.get('recent') == '0':
seis = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain += ['|', ('last_visit', '=', False), ('last_visit', '<', seis)]
Pacientes = request.env['res.partner'].sudo()
total = Pacientes.search_count(domain)
tope = int(kw.get('limit') or self.EXPORT_MAX)
patients = Pacientes.search(domain, limit=tope, order='name asc')
return json_response({
'status': 'success',
'patients': [self._patient_to_dict(p) for p in patients],
'total': len(patients),
'total_disponible': total,
'truncado': len(patients) < total,
'limite': tope,
})
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']
# Campos sensibles que este endpoint nunca toca (el dinero no se
# edita por aquí: monedero y saldos se gestionan en sus módulos).
if vals:
previos = {}
for k in vals:
v = getattr(patient, k, None)
previos[k] = (v.name if hasattr(v, 'name') else v)
patient.write(vals)
cambios = []
for k, nuevo in vals.items():
anterior = previos.get(k)
nuevo_cmp = getattr(patient, k, None)
nuevo_cmp = (nuevo_cmp.name if hasattr(nuevo_cmp, 'name') else nuevo_cmp)
if str(anterior or '') != str(nuevo_cmp or ''):
cambios.append(k)
self._auditar('pacientes', f'paciente:{patient.id}', k, anterior, nuevo_cmp,
entity_label=patient.name,
notes='Edición de expediente')
if cambios:
try:
autor = self._frontend_res_user()
patient.sudo().message_post(
body='Expediente editado desde el frontend: ' + ', '.join(cambios),
author_id=autor.id if autor else None,
message_type='notification',
)
except Exception:
# El rastro principal es la auditoría; el chatter es extra
pass
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')
pagos = request.env['skeen.pago'].sudo().search(
[('partner_id', '=', patient_id)], order='payment_date desc, id desc', limit=200)
# 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],
'pagos': [self._payment_to_dict(p) for p in pagos],
'estado_cuenta': estado_cuenta,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/check_phone', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patients_check_phone(self, **kw):
"""¿Ya existe un paciente activo con este teléfono (phone o mobile)?
Body: {"phone": "...", "exclude_id": 123 opcional}. Comparación sobre
dígitos normalizados (los últimos N dígitos coinciden), misma convención
que la cita rápida: tolera '+52 ', espacios y guiones.
"""
try:
data = _parse_json_body()
phone = (data.get('phone') or '').strip()
try:
exclude_id = int(data.get('exclude_id') or 0)
except (TypeError, ValueError):
exclude_id = 0
digits = re.sub(r'\D', '', phone)
if len(digits) < 7:
return json_response({'status': 'success', 'exists': False, 'patient': None})
candidates = request.env['res.partner'].sudo().search_read(
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
['phone', 'mobile'], limit=10000)
match_id = None
for c in candidates:
if exclude_id and c['id'] == exclude_id:
continue
for f in ('phone', 'mobile'):
c_digits = re.sub(r'\D', '', c.get(f) or '')
n = min(len(c_digits), len(digits))
if n >= 7 and c_digits[-n:] == digits[-n:]:
match_id = c['id']
break
if match_id:
break
if not match_id:
return json_response({'status': 'success', 'exists': False, 'patient': None})
patient = request.env['res.partner'].sudo().browse(match_id)
return json_response({
'status': 'success',
'exists': True,
'patient': {
'id': patient.id,
'name': patient.name,
'patient_id': patient.patient_id or '',
'phone': patient.phone or '',
'mobile': patient.mobile or '',
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/packages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_packages(self, patient_id, **kw):
"""Paquetes del paciente con sesiones restantes.
El sistema no guarda un contador de sesiones: el paquete se materializa
como las citas creadas para un servicio con `package_sessions > 0`
(create_appointment genera una cita por sesión). Por eso:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
restantes = total - usadas
"""
try:
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
('partner_id', '=', patient_id),
('state', 'not in', INACTIVE_STATES),
('servicio_id.package_sessions', '>', 0),
])
grupos = {}
for c in citas:
g = grupos.setdefault(c.servicio_id.id, {
'service_id': c.servicio_id.id,
'name': c.servicio_id.name,
'package_sessions': c.servicio_id.package_sessions,
'total': 0,
'used': 0,
'next_date': None,
})
g['total'] += 1
if c.state in ('arrived', 'in_progress', 'done'):
g['used'] += 1
elif c.date and (g['next_date'] is None or c.date.strftime('%Y-%m-%d') < g['next_date']):
g['next_date'] = c.date.strftime('%Y-%m-%d')
rows = []
for g in grupos.values():
g['remaining'] = max(0, g['total'] - g['used'])
rows.append(g)
rows.sort(key=lambda r: (-r['remaining'], r['name']))
return json_response({'status': 'success', 'packages': rows})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Adeudos: cobro con recibo y plan de pagos
# ============================================================
def _adeudo_ventas(self, patient_id):
"""Ventas con saldo pendiente del paciente, más antiguas primero."""
return request.env['skeen.venta'].sudo().search([
('partner_id', '=', patient_id),
('state', '!=', 'cancelled'),
('amount_due', '>', 0),
], order='date asc, id asc')
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/wallet', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_wallet(self, patient_id, **kw):
"""Saldo a favor del paciente + sus movimientos narrados.
El saldo es el del monedero (1 pt = $1 MXN): incluye depósitos
(anticipos), puntos ganados por compra, canjes y ajustes.
"""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
monedero = self._monedero_de(partner, crear=False)
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate = tc.rate if tc else None
saldo = monedero.points if monedero else 0
txs = monedero.transaction_ids.sorted('id', reverse=True)[:200] if monedero else []
refs = {t.reference for t in txs if t.reference and t.reference.startswith('VENTA-')}
totales = {}
if refs:
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
totales[v.name] = v.total
return json_response({
'status': 'success',
'patient_id': partner.id,
'patient': partner.name,
'wallet_id': monedero.id if monedero else None,
'points': saldo,
'equivalent_mxn': float(saldo),
'equivalent_usd': round(saldo / rate, 2) if rate else None,
'rate': rate,
'depositado': float(sum(t.points for t in (monedero.transaction_ids if monedero else []) if t.type == 'deposito')),
'movimientos': [{
'id': t.id,
'type': t.type,
'type_label': dict(
request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
'points': t.points,
'narration': self._tx_narration(t, totales),
'description': t.description or '',
'reference': t.reference or '',
'operator': t.operator or '',
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
} for t in txs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
def patient_deposit(self, patient_id, **kw):
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
Registra el cobro real (skeen.pago) y el movimiento `deposito` en el
monedero, y devuelve el recibo imprimible. Permiso: recepción o más
(recepción cobra anticipos); el ajuste manual sigue siendo admin/gerencia.
"""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
try:
amount = float(data.get('amount') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Monto inválido'}, 400)
if amount <= 0:
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
method = data.get('payment_method') or ''
metodos = ('cash', 'card', 'transfer', 'stripe', 'mercadopago')
if method not in metodos:
return json_response({
'status': 'error',
'message': 'Método de pago inválido (efectivo, tarjeta, transferencia, stripe o mercadopago)',
}, 400)
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
nota = (data.get('notes') or '').strip()
cita_id = int(data.get('cita_id') or 0) or False
pago = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id,
'cita_id': cita_id,
'amount': amount,
'payment_method': method,
'state': 'completed',
'payment_date': fields.Datetime.now(),
'provider_reference': nota or '',
})
monedero = self._monedero_de(partner, crear=True)
tx = monedero.add_deposit(
amount,
description=nota or 'Recarga de saldo a favor',
reference=pago.name,
operator=usuario,
)
self._auditar('monedero', f'paciente:{partner.id}', 'deposito', 0, amount,
entity_label=partner.name, notes=nota or 'Recarga de saldo a favor')
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',
'movimiento': {
'id': tx.id,
'type': tx.type,
'points': tx.points,
'narration': self._tx_narration(tx, {}),
'operator': tx.operator or '',
},
'wallet': {
'id': monedero.id,
'points': monedero.points,
'equivalent_mxn': monedero.equivalent_mxn,
'equivalent_usd': round(monedero.points / rate, 2) if rate else None,
},
'receipt': {
'tipo': 'deposito',
'folio': pago.name,
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
'patient_id': partner.id,
'patient': partner.name,
'patient_code': partner.patient_id or '',
'phone': partner.phone or '',
'amount': amount,
'payment_method': method,
'adeudo_anterior': 0.0,
'saldo_restante': 0.0,
'saldo_favor': monedero.points,
'aplicado': [],
'nota': nota,
'operator': usuario,
'wallet_points': monedero.points,
},
}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _monedero_de(self, partner, crear=False):
"""Monedero del paciente (opcionalmente lo crea si no existe)."""
Monedero = request.env['skeen.monedero'].sudo()
monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1)
if not monedero and crear:
monedero = Monedero.create({'partner_id': partner.id})
return monedero
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
def pay_patient_adeudo(self, patient_id, **kw):
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
El pago se aplica a las ventas con saldo pendiente, de la más antigua a
la más reciente, replicando la lógica de /sales/<id>/pay (incluye los
puntos de monedero al liquidar una venta).
"""
try:
data = _parse_json_body()
try:
amount = float(data.get('amount') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Monto inválido'}, 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)
if amount <= 0:
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
ventas = self._adeudo_ventas(patient_id)
total_adeudo = round(sum(v.amount_due for v in ventas), 2)
if total_adeudo <= 0:
return json_response({'status': 'error', 'message': 'El paciente no tiene adeudo pendiente'}, 400)
if round(amount, 2) > total_adeudo + 0.01:
return json_response({
'status': 'error',
'message': f'El monto excede el adeudo pendiente (${total_adeudo:,.2f})',
}, 400)
payment = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id,
'amount': amount,
'payment_method': method,
'state': 'completed',
'payment_date': fields.Datetime.now(),
})
restante = round(amount, 2)
aplicado = []
primera_asignacion = True
for venta in ventas:
if restante <= 0:
break
por_aplicar = round(min(restante, venta.amount_due), 2)
if por_aplicar <= 0:
continue
if primera_asignacion and por_aplicar == round(amount, 2):
# El pago cubre una sola venta: se liga para poder verlo en Tickets
payment.write({'venta_id': venta.id})
primera_asignacion = False
nuevo_pagado = round(venta.amount_paid + por_aplicar, 2)
if nuevo_pagado >= venta.total - 0.005:
venta.write({'amount_paid': nuevo_pagado})
venta.action_mark_paid() # otorga puntos y liquida la venta
nuevo_estado = 'paid'
else:
venta.write({'amount_paid': nuevo_pagado, 'state': 'partial'})
nuevo_estado = 'partial'
restante = round(restante - por_aplicar, 2)
aplicado.append({
'id': venta.id,
'name': venta.name,
'date': venta.date.strftime('%Y-%m-%d') if venta.date else None,
'applied': por_aplicar,
'total': venta.total,
'state': nuevo_estado,
'amount_due': venta.amount_due,
})
saldo = round(sum(v.amount_due for v in self._adeudo_ventas(patient_id)), 2)
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', partner.id)], limit=1)
return json_response({
'status': 'success',
'payment': self._payment_to_dict(payment),
'receipt': {
'folio': payment.name,
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
'patient_id': partner.id,
'patient': partner.name,
'patient_code': partner.patient_id or '',
'phone': partner.phone or '',
'amount': amount,
'payment_method': method,
'adeudo_anterior': total_adeudo,
'saldo_restante': saldo,
'aplicado': aplicado,
'operator': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
'wallet_points': monedero.points if monedero else 0,
},
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/payment_plan', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def set_payment_plan(self, patient_id, **kw):
"""Acuerdo de pagos del adeudo: N pagos de $X, siguiente fecha y nota.
Enviar count<=0 limpia el plan. No hay recordatorios automáticos: es
solo registro y visualización.
"""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
try:
count = int(data.get('count') or 0)
plan_amount = float(data.get('amount') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Valores inválidos'}, 400)
if count > 0:
if plan_amount <= 0:
return json_response({'status': 'error', 'message': 'El monto por pago debe ser mayor a 0'}, 400)
partner.write({
'payment_plan_count': count,
'payment_plan_amount': plan_amount,
'next_payment_date': data.get('next_payment_date') or False,
'payment_plan_note': data.get('note') or '',
'payment_plan_start': fields.Date.today(),
'payment_plan_since': fields.Datetime.now(),
'payment_plan_by': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
})
else:
partner.write({
'payment_plan_count': 0,
'payment_plan_amount': 0.0,
'next_payment_date': False,
'payment_plan_note': '',
'payment_plan_start': False,
'payment_plan_since': False,
'payment_plan_by': '',
})
return json_response({'status': 'success', 'plan': self._payment_plan_dict(partner)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _payment_plan_dict(self, partner):
"""Plan del paciente + pagos ya cubiertos desde que se acordó.
Sólo cuentan los pagos posteriores al acuerdo (payment_plan_since): los
que ya se habían cobrado antes no abonan al plan recién registrado.
"""
count = partner.payment_plan_count or 0
pagados = 0
if count > 0 and partner.payment_plan_since:
pagados = request.env['skeen.pago'].sudo().search_count([
('partner_id', '=', partner.id),
('state', '=', 'completed'),
('create_date', '>=', partner.payment_plan_since),
])
return {
'count': count,
'amount': partner.payment_plan_amount or 0.0,
'next_payment_date': partner.next_payment_date.strftime('%Y-%m-%d') if partner.next_payment_date else None,
'note': partner.payment_plan_note or '',
'start': partner.payment_plan_start.strftime('%Y-%m-%d') if partner.payment_plan_start else None,
'by': partner.payment_plan_by or '',
'paid_count': min(pagados, count) if count else 0,
'remaining_count': max(0, count - pagados) if count else 0,
}
@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 '',
'antes_despues': a.antes_despues,
'visita_id': a.visita_id.id if a.visita_id else None,
'cita_id': a.cita_id.id if a.cita_id else None,
'servicio_id': a.servicio_id.id if a.servicio_id else None,
'servicio': a.servicio_id.name if a.servicio_id else '',
'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_vals = {
'partner_id': patient.id,
'kind': kind,
'name': data.get('name') or 'archivo',
'file': file_b64,
'mimetype': data.get('mimetype') or '',
'notes': data.get('notes', ''),
}
if data.get('antes_despues') in ('sin_clasificar', 'antes', 'despues'):
adj_vals['antes_despues'] = data['antes_despues']
for key in ('visita_id', 'cita_id', 'servicio_id'):
if data.get(key):
adj_vals[key] = int(data[key])
adj = request.env['skeen.patient.adjunto'].sudo().create(adj_vals)
# Si clasifican la foto y no dieron servicio, heredarlo de la visita
if adj.visita_id and not adj.servicio_id:
adj.servicio_id = adj.visita_id.servicio_id
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=['PUT'], csrf=False)
@require_role('recepcion')
def update_patient_adjunto(self, adj_id, **kw):
"""Actualiza la clasificación antes/después (y contexto) de un adjunto."""
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)
data = _parse_json_body()
vals = {}
if data.get('antes_despues') in ('sin_clasificar', 'antes', 'despues'):
vals['antes_despues'] = data['antes_despues']
for key in ('visita_id', 'cita_id', 'servicio_id'):
if key in data:
vals[key] = int(data[key]) if data.get(key) else None
if vals:
adj.write(vals)
if adj.visita_id and not adj.servicio_id:
adj.servicio_id = adj.visita_id.servicio_id
return json_response({'status': 'success', 'adjunto': self._patient_adjunto_to_dict(adj)})
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)] + _patient_search_domain(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', INACTIVE_STATES), '|',
('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 '',
'allergies': p.allergies 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()
# Orden: manual (sequence, default) o por más vendidos de los
# últimos 90 días. "best_sellers" ordena en memoria porque el
# ranking sale de las líneas de venta, no del catálogo.
sort = kw.get('sort') or 'manual'
ventas_90d = {}
if sort == 'best_sellers':
desde = (date.today() - timedelta(days=90)).strftime('%Y-%m-%d')
lineas = request.env['skeen.venta.line'].sudo().read_group(
[('service_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', desde)],
['service_id', 'quantity:sum'], ['service_id'])
ventas_90d = {g['service_id'][0]: (g.get('quantity') or 0.0) for g in lineas}
if sort == 'best_sellers':
todos = Servicio.search(domain, order='sequence asc, name asc')
todos = todos.sorted(
key=lambda s: (-ventas_90d.get(s.id, 0.0), s.sequence or 0, s.name or ''))
total = len(todos)
services = todos[(page - 1) * page_size: page * page_size]
else:
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, ventas_90d) for s in services],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'sort': sort,
})
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/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def reorder_services(self, **kw):
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
Los servicios de `ids` quedan, en ese mismo orden, ocupando la posición
del primero de ellos (así funcionan las flechas subir/bajar: se envía la
página ya reordenada). Con `to: 'front'` el bloque pasa al inicio del
catálogo (botón "Mostrar primero en POS"). Con la numeración
normalizada el orden manual es estable y el POS lo respeta al listar.
"""
try:
data = _parse_json_body()
raw = data.get('ids') or []
if not isinstance(raw, list) or not raw:
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
try:
ids = [int(i) for i in raw]
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
Servicio = request.env['skeen.servicio'].sudo()
todos = Servicio.search([], order='sequence asc, name asc')
orden = [s.id for s in todos]
mover_set = set(ids)
mover = [i for i in ids if i in set(orden)]
if not mover:
return json_response({'status': 'error', 'message': 'Ningún servicio válido en la lista'}, 404)
resto = [i for i in orden if i not in mover_set]
if (data.get('to') or '').lower() == 'front':
nuevo = mover + resto
else:
primera = orden.index(mover[0])
pos = len([i for i in orden[:primera] if i not in mover_set])
nuevo = resto[:pos] + mover + resto[pos:]
for idx, sid in enumerate(nuevo):
Servicio.browse(sid).write({'sequence': (idx + 1) * 10})
return json_response({'status': 'success', 'total': len(nuevo), 'ids': nuevo[:len(ids)]})
except Exception as e:
request.env.cr.rollback()
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', 'sequence']
vals = {k: data[k] for k in allowed if k in data}
Servicio = request.env['skeen.servicio'].sudo()
if 'sequence' in vals:
try:
vals['sequence'] = int(vals['sequence'])
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400)
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, ventas_90d=None):
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,
'sequence': s.sequence or 0,
'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None,
'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:
# Rol medico: la agenda necesita la lista de doctores, pero los KPIs
# de dinero por médico son del módulo Médicos (admin/recepción).
fu = current_user(request.env)
hide_money = bool(fu and fu.role == 'medico')
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')
# ---- Ventas del mes en curso (para la meta mensual) ----
mes_ini = date.today().replace(day=1).strftime('%Y-%m-%d')
mes_total = {}
for g in request.env['skeen.venta.line'].sudo().read_group(
[('prescribed_by_id', '!=', False), ('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', mes_ini)],
['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']):
mes_total[g['prescribed_by_id'][0]] = round(g['subtotal'] or 0.0, 2)
# Recomendaciones: pacientes captados en 30d asignados al médico
# con "recomendado por" capturado (misma métrica visible del legacy)
recomendaciones = {}
for g in request.env['res.partner'].sudo().read_group(
[('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids),
('referred_by', 'not in', [False, '']), ('create_date', '>=', since)],
[], ['primary_doctor_id']):
recomendaciones[g['primary_doctor_id'][0]] = g['primary_doctor_id_count']
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': 0.0 if hide_money else (d.commission_pct or 0.0),
'staff_type': d.staff_type or 'doctor',
'monthly_goal': 0.0 if hide_money else (d.monthly_goal or 0.0),
'month_total': 0.0 if hide_money else mes_total.get(d.id, 0.0),
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in d.horario_ids],
'tiene_horario': bool(d.horario_ids),
'horario_resumen': d._horario_resumen(),
'patient_count': counts.get(d.id, 0),
'recomendaciones_30d': recomendaciones.get(d.id, 0),
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
'total_30d': 0.0 if hide_money else round(stats.get(d.id, {}).get('total', 0.0), 2),
'total_30d_usd': None if hide_money else (round(stats.get(d.id, {}).get('total', 0.0) / rate, 2) if rate else None),
'card_30d': 0.0 if hide_money else 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 'monthly_goal' in data:
try:
meta = float(data.get('monthly_goal') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'La meta mensual debe ser numérica'}, 400)
if meta < 0:
return json_response({'status': 'error', 'message': 'La meta mensual no puede ser negativa'}, 400)
vals['monthly_goal'] = meta
if 'staff_type' in data:
if data['staff_type'] not in ('doctor', 'cosmetologa'):
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
vals['staff_type'] = data['staff_type']
if vals:
previos = {k: getattr(doctor, k) for k in vals}
doctor.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
modulo = 'metas' if campo == 'monthly_goal' else 'comisiones'
self._auditar(modulo, f'doctor:{doctor.id}', campo,
anterior, nuevo, entity_label=doctor.name)
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,
'monthly_goal': doctor.monthly_goal or 0.0,
'staff_type': doctor.staff_type or 'doctor',
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_doctor_horario(self, doctor_id, **kw):
"""Horario semanal del médico (líneas por día que atiende)."""
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)
return json_response({
'status': 'success',
'doctor_id': doctor.id,
'tiene_horario': bool(doctor.horario_ids),
'horario_resumen': doctor._horario_resumen(),
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in doctor.horario_ids],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/horario', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_doctor_horario(self, doctor_id, **kw):
"""Reemplaza el horario semanal del médico.
Body: {"horario": [{"day_of_week": "0", "hour_from": 9, "hour_to": 14}, ...]}
Una lista vacía deja al médico SIN horario configurado (la agenda no
restringe). No toca las citas ya agendadas.
"""
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()
lineas = data.get('horario')
if not isinstance(lineas, list):
return json_response({'status': 'error', 'message': 'Falta la lista de horario'}, 400)
vals = []
vistos = set()
for l in lineas:
dia = str(l.get('day_of_week', '')).strip()
if dia not in [str(i) for i in range(7)]:
return json_response({'status': 'error', 'message': f'Día inválido: {dia}'}, 400)
if dia in vistos:
return json_response({'status': 'error', 'message': 'Hay días repetidos en el horario'}, 400)
try:
hf = float(l.get('hour_from'))
ht = float(l.get('hour_to'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not (0 <= hf < ht <= 24):
return json_response({
'status': 'error',
'message': 'La hora fin debe ser mayor a la hora inicio y estar entre 0:00 y 24:00',
}, 400)
vistos.add(dia)
vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht}))
previo = doctor._horario_resumen() or '(sin horario)'
doctor.write({'horario_ids': [(5, 0, 0)] + vals})
nuevo = doctor._horario_resumen() or '(sin horario)'
if previo != nuevo:
self._auditar('horarios', f'doctor:{doctor.id}', 'horario',
previo, nuevo, entity_label=doctor.name)
return json_response({
'status': 'success',
'horario': [{
'day_of_week': h.day_of_week,
'hour_from': h.hour_from,
'hour_to': h.hour_to,
} for h in doctor.horario_ids],
'tiene_horario': bool(doctor.horario_ids),
'horario_resumen': doctor._horario_resumen(),
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/commission', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_doctor_commission(self, doctor_id, **kw):
"""Designa el % de comisión de un doctor (0-100). 0 = usa el % global."""
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()
try:
pct = float(data.get('commission_pct'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
prev = doctor.commission_pct or 0.0
doctor.write({'commission_pct': pct})
if prev != pct:
self._auditar('comisiones', f'doctor:{doctor.id}', 'commission_pct',
prev, pct, entity_label=doctor.name)
return json_response({
'status': 'success',
'doctor': {'id': doctor.id, 'name': doctor.name, 'commission_pct': doctor.commission_pct},
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def doctor_history(self, doctor_id, **kw):
"""Historial de actividad del médico: citas, visitas y líneas recetadas.
Devuelve las últimas 50 de cada lista (más recientes primero) y
`totales` sobre TODO el historial (no solo las 50).
"""
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)
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([('doctor_id', '=', doctor_id)], order='date desc, time desc', limit=50)
citas_total = Citas.search_count([('doctor_id', '=', doctor_id)])
Visitas = request.env['skeen.visita'].sudo()
visitas = Visitas.search([('doctor_id', '=', doctor_id)], order='date_start desc', limit=50)
visitas_total = Visitas.search_count([('doctor_id', '=', doctor_id)])
Lineas = request.env['skeen.venta.line'].sudo()
line_domain = [('prescribed_by_id', '=', doctor_id)]
lineas = Lineas.search(line_domain, order='id desc', limit=50)
agg = Lineas.read_group(line_domain, ['subtotal:sum'], [])
ventas_total_lineas = agg[0]['__count'] if agg else 0
monto_total = (agg[0]['subtotal'] or 0.0) if agg else 0.0
return json_response({
'status': 'success',
'citas': [{
'id': c.id,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'time': c.time_str or '',
'patient': c.partner_id.name,
'service': c.servicio_id.name,
'state': c.state,
} for c in citas],
'visitas': [{
'id': v.id,
'date_start': v.date_start.strftime('%Y-%m-%d %H:%M') if v.date_start else None,
'patient': v.partner_id.name,
'motivo': v.motivo or '',
'state': v.state,
} for v in visitas],
'ventas': [{
'id': l.id,
'venta': l.venta_id.name,
'date': l.venta_id.date.strftime('%Y-%m-%d') if l.venta_id.date else None,
'patient': l.venta_id.partner_id.name,
'item': l.description or (l.service_id.name if l.service_id else (l.item_id.name if l.item_id else '')),
'subtotal': l.subtotal,
'state': l.venta_id.state,
} for l in lineas],
'totales': {
'citas_total': citas_total,
'visitas_total': visitas_total,
'ventas_total_lineas': ventas_total_lineas,
'monto_total': round(monto_total, 2),
},
})
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)
@http.route('/skeen/frontend/v1/my/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('medico')
def my_sales(self, **kw):
""""Mis ventas": ventas y comisión del médico autenticado.
Atribución: líneas de venta recetadas por su hr.employee
(prescribed_by_id), misma métrica que el módulo Médicos y el reporte
de comisiones. NUNCA acepta un doctor_id externo: siempre usa el
usuario del token, así un médico no puede ver ventas de otro.
range: 30d (default) | month (este mes) | last_month (mes pasado).
Comisión: hr.employee.commission_pct si > 0; si no, el parámetro
global ir.config_parameter 'skeen.commission_pct' (default 0).
"""
try:
user = request.frontend_user
if not user or user.role != 'medico':
return json_response({'status': 'error', 'message': 'Solo disponible para rol médico'}, 403)
emp = self._linked_employee(user)
if not emp:
return json_response({'status': 'success', 'medico_sin_employee': True,
'sales': [], 'daily_breakdown': [],
'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0},
'commission_pct': 0.0, 'commission_estimate': 0.0})
today = date.today()
range_ = kw.get('range', '30d')
if range_ == 'month':
date_from, date_to = today.replace(day=1), today
elif range_ == 'last_month':
date_to = today.replace(day=1) - timedelta(days=1)
date_from = date_to.replace(day=1)
else:
range_ = '30d'
date_from, date_to = today - timedelta(days=30), today
Lineas = request.env['skeen.venta.line'].sudo()
rows = Lineas.search_read([
('prescribed_by_id', '=', emp.id),
('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', date_from.strftime('%Y-%m-%d')),
('venta_id.date', '<=', date_to.strftime('%Y-%m-%d')),
], ['venta_id', 'subtotal', 'description', 'quantity'])
venta_ids = list({r['venta_id'][0] for r in rows})
ventas = request.env['skeen.venta'].sudo().browse(venta_ids)
# Tarjeta: exacto (card_amount) + aprox histórica por cita (misma
# lógica que list_doctors)
card_by_venta = {v.id: v.card_amount or 0.0 for v in ventas}
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)
lines_by_venta = {}
total_mxn = 0.0
for r in rows:
vid = r['venta_id'][0]
total_mxn += r['subtotal'] or 0.0
lines_by_venta.setdefault(vid, []).append(r)
# Serie diaria del rango (para la gráfica de Mis ventas): suma de
# líneas atribuidas por fecha de venta, con días sin ventas en 0.
date_by_venta = {v.id: v.date for v in ventas}
totals_by_date = {}
for r in rows:
vdate = date_by_venta.get(r['venta_id'][0])
if vdate:
key = vdate.strftime('%Y-%m-%d')
totals_by_date[key] = totals_by_date.get(key, 0.0) + (r['subtotal'] or 0.0)
daily_breakdown = []
day = date_from
while day <= date_to:
key = day.strftime('%Y-%m-%d')
daily_breakdown.append({'date': key, 'total': round(totals_by_date.get(key, 0.0), 2)})
day += timedelta(days=1)
def metodo_pago(v):
paid = v.amount_paid or 0.0
card = card_by_venta.get(v.id, 0.0)
if paid <= 0:
return 'Sin pagar'
if card >= paid:
return 'Tarjeta'
if card > 0:
return 'Mixto'
return 'Efectivo/Otro'
sales = sorted(({
'id': v.id,
'name': v.name,
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
'patient': v.partner_id.name,
'items': [l['description'] or '' for l in lines_by_venta.get(v.id, [])],
'amount': round(sum(l['subtotal'] or 0.0 for l in lines_by_venta.get(v.id, [])), 2),
'payment_method': metodo_pago(v),
'state': v.state,
} for v in ventas), key=lambda s: (s['date'] or '', s['id']), reverse=True)
# TC vigente (equivalente USD)
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate = tc.rate if tc else None
# % de comisión: por empleado; fallback al parámetro global
ICP = request.env['ir.config_parameter'].sudo()
global_pct = float(ICP.get_param('skeen.commission_pct', 0) or 0)
if emp.commission_pct:
pct, pct_source = emp.commission_pct, 'empleado'
else:
pct, pct_source = global_pct, 'global'
# Meta mensual: progreso del MES EN CURSO (independiente del rango
# seleccionado), para la barra meta vs real de "Mis ventas".
mes_ini = today.replace(day=1)
mes_rows = Lineas.read_group([
('prescribed_by_id', '=', emp.id),
('venta_id.state', '!=', 'cancelled'),
('venta_id.date', '>=', mes_ini.strftime('%Y-%m-%d')),
('venta_id.date', '<=', today.strftime('%Y-%m-%d')),
], ['subtotal:sum'], [])
mes_real = round((mes_rows[0]['subtotal'] or 0.0) if mes_rows else 0.0, 2)
meta = emp.monthly_goal or 0.0
return json_response({
'status': 'success',
'doctor': {'id': emp.id, 'name': emp.name},
'range': range_,
'date_from': date_from.strftime('%Y-%m-%d'),
'date_to': date_to.strftime('%Y-%m-%d'),
'kpis': {
'ventas': len(venta_ids),
'total_mxn': round(total_mxn, 2),
'total_usd': round(total_mxn / rate, 2) if rate else None,
'total_card': round(sum(card_by_venta.values()), 2),
},
'monthly_goal': {
'goal': meta,
'real': mes_real,
'pct': round(mes_real / meta * 100.0, 1) if meta else None,
'from': mes_ini.strftime('%Y-%m-%d'),
'to': today.strftime('%Y-%m-%d'),
},
'sales': sales,
'daily_breakdown': daily_breakdown,
'commission_pct': pct,
'commission_source': pct_source,
'commission_estimate': round(total_mxn * pct / 100.0, 2),
})
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 _tx_narration(self, tx, totales_ventas):
"""Narración legible del movimiento, generada del tipo + referencia."""
pts = tx.points or 0
abs_pts = abs(pts)
ref = (tx.reference or '').strip()
desc = (tx.description or '').strip()
desc_low = desc.lower()
operador = f' · por {tx.operator}' if tx.operator else ''
total = totales_ventas.get(ref)
if ref.startswith('VENTA-'):
monto = f' (${total:,.2f})' if total else ''
if tx.type == 'accrual':
return f'Ganó {abs_pts} pts por la venta {ref}{monto}'
if tx.type == 'redemption':
return f'Usó {abs_pts} pts en la venta {ref}{monto}'
if tx.type == 'adjustment':
if 'cancelaci' in desc_low or 'reversi' in desc_low:
return f'Reversión de {abs_pts} pts por cancelación de la venta {ref}'
if 'devoluci' in desc_low:
return f'Reversión de {abs_pts} pts por devolución de la venta {ref}'
return (desc or f'Ajuste de {pts} pts en la venta {ref}') + operador
if tx.type == 'expiration':
return f'Vencieron {abs_pts} pts de la venta {ref}'
if tx.type == 'deposito':
extra = f' · {desc}' if desc and desc_low not in ('recarga de saldo a favor', 'depósito') else ''
return f'Recargó ${abs_pts:,.2f} de saldo a favor{extra}{operador}'
if tx.type == 'reversion':
base = desc or f'Reversión de {abs_pts} pts'
return f'{base}{operador}'
if tx.type == 'adjustment':
if desc_low.startswith('ajuste manual'):
return f'{desc}{operador}'
return f'Ajuste manual: {desc}' if desc else f'Ajuste manual de {pts} pts{operador}'
if tx.type == 'accrual':
if desc and desc_low != 'acumulación de puntos':
return f'Acumulación de {abs_pts} pts · {desc}{operador}'
return f'Acumulación de {abs_pts} pts{operador}'
if tx.type == 'redemption':
if desc and desc_low != 'redención de puntos':
return f'Redención de {abs_pts} pts · {desc}{operador}'
return f'Redención de {abs_pts} pts{operador}'
if tx.type == 'expiration':
return f'Vencieron {abs_pts} pts'
return desc or f'Movimiento de {pts} pts'
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/transactions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def wallet_transactions(self, wallet_id, **kw):
"""Movimientos del monedero con narración legible y filtros."""
try:
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
if not monedero.exists():
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
domain = [('monedero_id', '=', monedero.id)]
ttype = kw.get('type')
if ttype in ('deposito', 'accrual', 'redemption', 'adjustment', 'reversion', 'expiration'):
domain.append(('type', '=', ttype))
if kw.get('date_from'):
domain.append(('create_date', '>=', f"{kw['date_from']} 00:00:00"))
if kw.get('date_to'):
domain.append(('create_date', '<=', f"{kw['date_to']} 23:59:59"))
tx = request.env['skeen.monedero.transaction'].sudo().search(
domain, order='create_date desc, id desc', limit=300)
# Totales de las ventas referenciadas (para narrar el monto)
refs = {t.reference for t in tx if t.reference and t.reference.startswith('VENTA-')}
totales = {}
if refs:
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
totales[v.name] = v.total
movimientos = [{
'id': t.id,
'type': t.type,
'type_label': dict(request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
'points': t.points,
'narration': self._tx_narration(t, totales),
'description': t.description or '',
'reference': t.reference or '',
'operator': t.operator or '',
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
} for t in tx]
return json_response({
'status': 'success',
'wallet': self._wallet_to_dict(monedero),
'movimientos': movimientos,
'total': len(movimientos),
'sum_positive': sum(m['points'] for m in movimientos if m['points'] > 0),
'sum_negative': sum(m['points'] for m in movimientos if m['points'] < 0),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/adjust', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def wallet_adjust(self, wallet_id, **kw):
"""Ajuste manual auditado de puntos (permiso admin/gerencia).
Body: {"points": 50 | -20, "reason": "cortesía"}. El motivo es
obligatorio y queda en la descripción del movimiento junto con el
usuario del frontend que lo capturó.
"""
try:
if request.frontend_user.role not in ('admin', 'gerencia'):
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
if not monedero.exists():
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
data = _parse_json_body()
reason = (data.get('reason') or '').strip()
if not reason:
return json_response({'status': 'error', 'message': 'El motivo del ajuste es obligatorio'}, 400)
try:
points = int(data.get('points') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Cantidad de puntos inválida'}, 400)
if points == 0:
return json_response({'status': 'error', 'message': 'La cantidad de puntos no puede ser 0'}, 400)
operador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
aplicado = monedero.adjust_points(
points,
description=f'Ajuste manual: {reason}',
reference='AJUSTE-MANUAL',
operator=operador,
)
tx = request.env['skeen.monedero.transaction'].sudo().search(
[('monedero_id', '=', monedero.id)], order='id desc', limit=1)
return json_response({
'status': 'success',
'wallet': self._wallet_to_dict(monedero),
'applied': aplicado,
'movimiento': {
'id': tx.id,
'points': tx.points,
'date': tx.create_date.strftime('%Y-%m-%d %H:%M') if tx.create_date else None,
'operator': tx.operator or '',
'narration': self._tx_narration(tx, {}),
},
})
except Exception as e:
request.env.cr.rollback()
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')
@forbid_roles('marketing')
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')
@forbid_roles('marketing')
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('payment_method'):
domain.append(('payment_method', '=', kw['payment_method']))
if kw.get('user'):
domain.append(('user_login', 'ilike', kw['user']))
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)
pagos = {}
for p in request.env['skeen.pago'].sudo().search([('venta_id', 'in', sales.ids)]):
pagos.setdefault(p.venta_id.id, []).append(self._payment_to_dict(p))
return json_response({
'status': 'success',
'sales': [{**self._sale_to_dict(v), 'pagos': pagos.get(v.id, [])} 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')
@forbid_roles('marketing')
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')
@forbid_roles('marketing')
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,
'venta_id': sale.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 not sale.payment_method:
vals['payment_method'] = method
elif sale.payment_method != method:
vals['payment_method'] = 'mixed'
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')
@forbid_roles('marketing')
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)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
def cancel_sale(self, sale_id, **kw):
"""Cancelación de venta con nota de crédito (motivo obligatorio).
Permiso: admin y gerencia (el rango de 'gerencia' no alcanza a
require_role('admin'), por eso el chequeo explícito).
"""
try:
if request.frontend_user.role not in ('admin', 'gerencia'):
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
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()
reason = (data.get('reason') or '').strip()
if not reason:
return json_response({'status': 'error', 'message': 'El motivo de cancelación es obligatorio'}, 400)
result = sale.action_cancel_with_reason(
reason=reason,
cancelled_by=request.frontend_user.login,
restore_stock=bool(data.get('restore_stock', True)),
)
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/last', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def last_sale(self, **kw):
"""Última venta del paciente, con precios ACTUALES del catálogo.
Pensado para "Repetir última venta" en el POS: devuelve las líneas con
la cantidad vendida la última vez y el precio vigente hoy, marcando
precio cambiado / artículo faltante o inactivo.
"""
try:
patient_id = int(kw.get('patient_id', 0) or 0)
if not patient_id:
return json_response({'status': 'error', 'message': 'Falta patient_id'}, 400)
Ventas = request.env['skeen.venta'].sudo()
last = Ventas.search(
[('partner_id', '=', patient_id), ('state', '!=', 'cancelled')],
order='create_date desc, id desc', limit=1)
if not last:
return json_response({'status': 'success', 'sale': None, 'lines': [], 'warnings': []})
lines = []
warnings = []
for line in last.line_ids:
row = {
'service_id': line.service_id.id if line.service_id else None,
'item_id': line.item_id.id if line.item_id else None,
'name': line.service_id.name if line.service_id else (line.item_id.name if line.item_id else line.description),
'description': line.description or '',
'kind': 'service' if line.service_id else 'item',
'quantity': line.quantity,
'price_last': line.price_unit,
'price_current': None,
'price_changed': False,
'missing': False,
'stock': None,
'unit': None,
'active': True,
}
if line.service_id:
svc = line.service_id
row.update({
'price_current': svc.price,
'price_changed': round(svc.price, 2) != round(line.price_unit, 2),
'active': svc.active,
'missing': not svc.active,
})
if not svc.active:
warnings.append(f'{svc.name}: el servicio ya no está activo')
elif row['price_changed']:
warnings.append(f'{svc.name}: el precio cambió de ${line.price_unit:g} a ${svc.price:g}')
else:
item = line.item_id
row.update({
'price_current': item.price,
'price_changed': round((item.price or 0), 2) != round(line.price_unit, 2),
'stock': item.qty,
'unit': item.unit,
'active': item.active,
'missing': not item.active,
})
if not item.active:
warnings.append(f'{item.name}: el artículo ya no está activo')
elif row['price_changed']:
warnings.append(f'{item.name}: el precio cambió de ${line.price_unit:g} a ${(item.price or 0):g}')
lines.append(row)
return json_response({
'status': 'success',
'sale': self._sale_to_dict(last),
'lines': lines,
'warnings': warnings,
})
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,
'notes': v.notes or '',
'cancel_reason': v.cancel_reason or '',
'cancelled_by': v.cancelled_by or '',
'cancelled_at': v.cancelled_at.strftime('%Y-%m-%d %H:%M') if v.cancelled_at else None,
'cancel_points_reversed': v.cancel_points_reversed,
'cancel_points_restored': v.cancel_points_restored,
'payment_method': v.payment_method or '',
'user_login': v.user_login or '',
'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')
@forbid_roles('marketing')
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)
cobrador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
venta = request.env['skeen.venta'].sudo().create({
'partner_id': partner.id,
'discount': discount,
'line_ids': line_vals,
'user_login': cobrador,
})
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)
# Método del ticket: puntos si el saldo cubrió todo, mixto si fue
# parte en saldo y parte en dinero
if puntos_usados and restante <= 0:
venta.write({'payment_method': 'points'})
elif puntos_usados and restante > 0:
venta.write({'payment_method': 'mixed'})
else:
venta.write({'payment_method': method})
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,
'venta_id': venta.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)
# Los pagos pueden llegar después de abrir la caja: refrescar
# los esperados de los cortes abiertos.
closings.filtered(lambda c: c.state == 'open').refresh_totals()
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')
@forbid_roles('marketing')
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', ''),
# Atribución explícita: con auth='none' el uid de Odoo es nulo
'user_id': self._frontend_res_user().id,
}
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')
@forbid_roles('marketing')
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)
closing.refresh_totals()
data = _parse_json_body()
vals = {}
for key in ('closing_cash', 'counted_cash', 'counted_card',
'counted_transfer', 'counted_other'):
if key in data:
try:
vals[key] = float(data[key] or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': f'Valor inválido: {key}'}, 400)
if 'notes' in data:
vals['notes'] = data.get('notes', '')
# Compatibilidad: si solo se envió el conteo, el cierre de efectivo
# registrado es el contado (difference del listado usa closing_cash).
if 'closing_cash' not in vals and 'counted_cash' in vals:
vals['closing_cash'] = vals['counted_cash']
if vals:
closing.write(vals)
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,
'counted_cash': c.counted_cash,
'counted_card': c.counted_card,
'counted_transfer': c.counted_transfer,
'counted_other': c.counted_other,
'diff_cash': c.diff_cash,
'diff_card': c.diff_card,
'diff_transfer': c.diff_transfer,
'diff_other': c.diff_other,
'notes': c.notes or '',
'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()
# Las ventas canceladas (nota de crédito) no cuentan como ingreso
ventas = Ventas.search([
('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled'),
])
# 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 = {}
partners = {}
if items:
for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]):
phones[p.id] = p.phone or ''
partners[p.id] = p
for i in items:
i['phone'] = phones.get(i['id'], '')
plan = self._payment_plan_dict(partners[i['id']]) if i['id'] in partners else None
i['plan'] = plan if plan and plan['count'] > 0 else None
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 (excluye canceladas: no son ingreso)
ventas = request.env['skeen.venta'].sudo().search([
('date', '=', date_str), ('state', '!=', 'cancelled'),
])
# 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', INACTIVE_STATES),
])
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:
# Las ventas de solo artículos no tienen servicio: read_group
# agrupa esas líneas bajo la clave False y no son "top servicios".
if not g.get('service_id'):
continue
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)
prev_rate = existing.rate if existing else None
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})
self._auditar('tipo_cambio', f'tipo_cambio:{rec.id}', 'rate',
prev_rate, rate, entity_label=f'TC {today}',
notes='Alta' if prev_rate is None else 'Actualización')
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)
@http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def get_commission_default(self, **kw):
"""% de comisión global por defecto (se aplica a médicos sin % propio)."""
try:
ICP = request.env['ir.config_parameter'].sudo()
pct = float(ICP.get_param('skeen.commission_pct', 0) or 0)
return json_response({'status': 'success', 'commission_pct': pct})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
def set_commission_default(self, **kw):
try:
data = _parse_json_body()
try:
pct = float(data.get('commission_pct'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
ICP = request.env['ir.config_parameter'].sudo()
prev = float(ICP.get_param('skeen.commission_pct', 0) or 0)
ICP.set_param('skeen.commission_pct', pct)
self._auditar('comisiones', 'commission_default', 'commission_pct',
prev, pct, entity_label='Comisión global')
return json_response({'status': 'success', 'commission_pct': pct})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Simulación de impacto y auditoría de configuración
# ============================================================
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_impact_simulation(self, **kw):
"""Qué habría pasado con los valores configurados (o unos propuestos).
Calcula, con las ventas atribuidas REALES del periodo, cuánto gana cada
médico por comisión. Parámetros opcionales `commission_pct` y `rate`
permiten probar valores candidatos sin guardarlos.
"""
try:
start = kw.get('date_from') or kw.get('start')
end = kw.get('date_to') or kw.get('end')
ICP = request.env['ir.config_parameter'].sudo()
pct_global = float(ICP.get_param('skeen.commission_pct', 0) or 0)
propuesto = kw.get('commission_pct')
pct_propuesto = None
if propuesto not in (None, ''):
try:
pct_propuesto = float(propuesto)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct_propuesto <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
vdomain = [('state', '!=', 'cancelled')]
if start:
vdomain.append(('date', '>=', start))
if end:
vdomain.append(('date', '<=', end))
ventas = request.env['skeen.venta'].sudo().search(vdomain)
lineas = request.env['skeen.venta.line'].sudo().search([
('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False),
])
base_por_doc = {}
for l in lineas:
did = l.prescribed_by_id.id
base_por_doc[did] = base_por_doc.get(did, 0.0) + (l.subtotal or 0.0)
Emp = request.env['hr.employee'].sudo()
filas = []
total_base = 0.0
total_comision = 0.0
for did, base in base_por_doc.items():
emp = Emp.browse(did)
propio = emp.commission_pct or 0.0
if pct_propuesto is not None:
pct, origen = pct_propuesto, 'propuesto'
elif propio > 0:
pct, origen = propio, 'propio'
else:
pct, origen = pct_global, 'global'
comision = base * pct / 100.0
total_base += base
total_comision += comision
filas.append({
'doctor_id': did,
'doctor': emp.name,
'job_title': emp.job_title or '',
'base': round(base, 2),
'commission_pct_propio': propio,
'commission_pct_efectivo': pct,
'origen_pct': origen,
'commission': round(comision, 2),
})
filas.sort(key=lambda f: -f['commission'])
# Equivalente USD del periodo con el TC vigente (o uno propuesto)
TC = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate_actual = TC.rate if TC else None
rate = rate_actual
if kw.get('rate') not in (None, ''):
try:
rate = float(kw.get('rate'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'rate debe ser numérico'}, 400)
if rate <= 0:
return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400)
return json_response({
'status': 'success',
'date_from': start,
'date_to': end,
'commission_pct_global': pct_global,
'commission_pct_propuesto': pct_propuesto,
'doctores': filas,
'total_base': round(total_base, 2),
'total_commission': round(total_comision, 2),
'ventas': len(ventas),
'rate': rate,
'rate_actual': rate_actual,
'total_usd': round(total_base / rate, 2) if rate else None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_audit_list(self, **kw):
"""Bitácora de cambios de configuración (filtrable y paginada)."""
try:
domain = []
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('module'):
domain.append(('module', '=', kw['module']))
if kw.get('user'):
domain += ['|', ('user_login', 'ilike', kw['user']), ('user_name', 'ilike', kw['user'])]
if kw.get('entity'):
e = kw['entity']
domain += ['|', ('entity', 'ilike', e), ('entity_label', 'ilike', e)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Audit = request.env['skeen.config.audit'].sudo()
total = Audit.search_count(domain)
recs = Audit.search(domain, order='date desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return json_response({
'status': 'success',
'registros': [{
'id': r.id,
'date': r.date.strftime('%Y-%m-%d %H:%M') if r.date else None,
'user_login': r.user_login or '',
'user_name': r.user_name or '',
'user_role': r.user_role or '',
'module': r.module,
'module_label': dict(
request.env['skeen.config.audit']._fields['module'].selection).get(r.module, r.module),
'entity': r.entity or '',
'entity_label': r.entity_label or '',
'field': r.field or '',
'old_value': r.old_value or '',
'new_value': r.new_value or '',
'notes': r.notes or '',
} for r in recs],
'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)
# ============================================================
# 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 '',
'whatsapp': p.whatsapp 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')
@forbid_roles('marketing')
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')
@forbid_roles('marketing')
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')
@forbid_roles('marketing')
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', 'cancelacion'):
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')
@forbid_roles('marketing')
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')
@forbid_roles('marketing')
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)
# ============================================================
# Conteo físico cíclico de inventario
# ============================================================
def _conteo_to_dict(self, c, with_lines=False):
d = {
'id': c.id,
'name': c.name,
'date': c.date.strftime('%Y-%m-%d %H:%M') if c.date else None,
'user_name': c.user_name or '',
'notes': c.notes or '',
'total_items': c.total_items,
'total_diferencias': c.total_diferencias,
'total_faltante': c.total_faltante,
'total_sobrante': c.total_sobrante,
'total_value_diff': c.total_value_diff,
}
if with_lines:
d['lines'] = [{
'id': l.id,
'item_id': l.item_id.id,
'item': l.item_id.name,
'unit': l.unit or 'pieza',
'qty_sistema': l.qty_sistema,
'qty_contado': l.qty_contado,
'difference': l.difference,
'cost': l.cost,
'value_diff': l.value_diff,
'move_id': l.move_id.id if l.move_id else None,
} for l in c.line_ids]
return d
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_conteos(self, **kw):
try:
limit = min(max(int(kw.get('limit', 30) or 30), 1), 100)
conteos = request.env['skeen.inventario.conteo'].sudo().search([], order='date desc, id desc', limit=limit)
return json_response({'status': 'success', 'conteos': [self._conteo_to_dict(c) for c in conteos]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/conteos/<int:conteo_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_conteo(self, conteo_id, **kw):
try:
conteo = request.env['skeen.inventario.conteo'].sudo().browse(conteo_id)
if not conteo.exists():
return json_response({'status': 'error', 'message': 'Conteo no encontrado'}, 404)
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
def create_conteo(self, **kw):
"""Registra un conteo físico y aplica los ajustes por diferencia.
Body: {"notes": "...", "lines": [{"item_id": 1, "qty_contado": 7.5}, ...]}
Solo se genera movimiento para las líneas con diferencia; el ajuste
fija la existencia al valor contado.
"""
try:
data = _parse_json_body()
raw_lines = data.get('lines') or []
if not isinstance(raw_lines, list) or not raw_lines:
return json_response({'status': 'error', 'message': 'Falta la lista de líneas contadas'}, 400)
Item = request.env['skeen.inventario.item'].sudo()
line_vals = []
for l in raw_lines:
try:
item_id = int(l.get('item_id') or 0)
qty_contado = float(l.get('qty_contado') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Línea con valores inválidos'}, 400)
item = Item.browse(item_id)
if not item.exists():
return json_response({'status': 'error', 'message': f'Artículo {item_id} no encontrado'}, 404)
line_vals.append((0, 0, {
'item_id': item.id,
'qty_sistema': item.qty,
'qty_contado': qty_contado,
'cost': item.cost,
}))
operador = getattr(request.frontend_user, 'name', '') or (
getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '')
conteo = request.env['skeen.inventario.conteo'].sudo().create({
'user_name': operador,
'notes': data.get('notes', ''),
'line_ids': line_vals,
})
ajustes = conteo.apply_count()
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True), 'ajustes': ajustes}, 201)
except Exception as e:
request.env.cr.rollback()
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