- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador' - frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el contexto de auth (fetch al boot + cache localStorage + document.title) - Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN: Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export), Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos), useClinicaPrint (fallback nombre), Login hace GET publico al montar - Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin) para editar nombre/pie/logos con preview - no renombrado (invisible al usuario): rutas API /skeen/*, claves localStorage, nombres de modulos, parametros skeen.* Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
6961 lines
350 KiB
Python
6961 lines
350 KiB
Python
# -*- 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 .auth import require_agenda, agenda_perm_error
|
||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||
from ..roles import AGENDA_PERM_KEYS
|
||
|
||
|
||
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')
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Permiso de agenda que exige cada accion del checklist de estado.
|
||
# 'visitas' = pasar a En cabina (arrive), que es lo que genera la visita del dia.
|
||
# 'cancel' es la baja de la cita -> eliminar. 'waitlist' mete a la lista de
|
||
# espera -> agendar. El resto son ediciones sobre la cita.
|
||
# ---------------------------------------------------------------------------
|
||
AGENDA_STATUS_PERM = {
|
||
'arrive': 'visitas',
|
||
'cancel': 'eliminar',
|
||
'waitlist': 'agendar',
|
||
'confirm': 'editar',
|
||
'start': 'editar',
|
||
'done': 'editar',
|
||
'no_show': 'editar',
|
||
'back': 'editar',
|
||
}
|
||
# Estado destino -> permiso, para 'next' y 'set_state' (no nombran la accion).
|
||
AGENDA_STATE_PERM = {'arrived': 'visitas', 'cancelled': 'eliminar'}
|
||
|
||
|
||
def _agenda_status_perm(action, target=None):
|
||
"""Permiso de agenda requerido por un cambio de estado de la cita."""
|
||
perm = AGENDA_STATUS_PERM.get(action)
|
||
if perm:
|
||
return perm
|
||
if target:
|
||
return AGENDA_STATE_PERM.get(target, 'editar')
|
||
return 'editar'
|
||
|
||
|
||
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 'agenda_perms' in data:
|
||
perms = data.get('agenda_perms')
|
||
if perms is not None and not isinstance(perms, list):
|
||
return json_response({'status': 'error', 'message': 'agenda_perms debe ser una lista o null'}, 400)
|
||
# None = heredar del rol (dejar el campo vacío)
|
||
vals['agenda_perms'] = json.dumps(perms) if perms is not None 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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('editar')
|
||
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')
|
||
@require_agenda('eliminar')
|
||
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')
|
||
# El permiso depende del estado destino: 'next' y 'set_state' no
|
||
# nombran la accion, asi que se resuelve contra la cita real.
|
||
_target = None
|
||
if action == 'next':
|
||
_idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
|
||
if 0 <= _idx < len(STATE_FLOW) - 1:
|
||
_target = STATE_FLOW[_idx + 1]
|
||
elif action == 'set_state':
|
||
_target = data.get('state')
|
||
denied = agenda_perm_error(
|
||
request.frontend_user, _agenda_status_perm(action, _target))
|
||
if denied is not None:
|
||
return denied
|
||
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')
|
||
@require_agenda('editar')
|
||
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')
|
||
@require_agenda('editar')
|
||
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')
|
||
@require_agenda('editar')
|
||
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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('agendar')
|
||
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 '',
|
||
'sala': r.sala or '',
|
||
'sala_label': dict(r._fields['sala'].selection).get(r.sala, '') if r.sala else '',
|
||
'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')
|
||
@require_agenda('agendar')
|
||
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 '',
|
||
'sala': data.get('sala') if data.get('sala') in ('consultoria', 'cabina') else False,
|
||
'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')
|
||
@require_agenda('agendar')
|
||
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 'sala' in data:
|
||
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar"
|
||
vals['sala'] = data['sala'] if data['sala'] in ('consultoria', 'cabina') else False
|
||
if vals['sala']:
|
||
record._log('Sala: %s' % dict(record._fields['sala'].selection).get(vals['sala'], vals['sala']),
|
||
user=request.frontend_user.login)
|
||
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')
|
||
@require_agenda('agendar')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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')
|
||
@require_agenda('visitas')
|
||
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 ValidationError as e:
|
||
# Reglas de negocio (p. ej. médico dado de baja): mensaje legible
|
||
return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400)
|
||
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 ValidationError as e:
|
||
return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400)
|
||
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.
|
||
|
||
Fuente principal: `skeen.paquete.compra`, el saldo que crea el POS al
|
||
vender un paquete y que la cita descuenta al atenderse.
|
||
|
||
De respaldo se mantiene la derivación antigua para pacientes migrados
|
||
del sistema legacy, donde el paquete nunca se registró como compra y
|
||
solo existe como las citas del servicio:
|
||
total = citas no canceladas del paciente con ese servicio
|
||
usadas = citas ya atendidas (llegó / en curso / completada)
|
||
restantes = total - usadas
|
||
"""
|
||
try:
|
||
Compras = request.env['skeen.paquete.compra'].sudo()
|
||
compras = Compras.search([
|
||
('partner_id', '=', patient_id),
|
||
('state', 'in', ('active', 'finished', 'expired')),
|
||
], order='date asc')
|
||
reales = []
|
||
servicios_con_compra = set()
|
||
for c in compras:
|
||
servicios_con_compra.add(c.service_id.id if c.service_id else None)
|
||
reales.append({
|
||
'id': c.id,
|
||
'compra': True,
|
||
'service_id': c.service_id.id if c.service_id else None,
|
||
'name': c.paquete_id.name if c.paquete_id else (c.service_id.name if c.service_id else ''),
|
||
'package_sessions': c.sessions_total,
|
||
'total': c.sessions_total,
|
||
'used': c.sessions_used,
|
||
'remaining': c.sessions_remaining,
|
||
'next_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||
'state': c.state,
|
||
})
|
||
if reales:
|
||
heredados = self._paquetes_heredados(patient_id, servicios_con_compra)
|
||
return json_response({
|
||
'status': 'success',
|
||
'packages': sorted(reales + heredados,
|
||
key=lambda r: (-r['remaining'], r['name'])),
|
||
})
|
||
return json_response({'status': 'success',
|
||
'packages': self._paquetes_heredados(patient_id, set())})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
def _paquetes_heredados(self, patient_id, excluir_servicios):
|
||
"""Saldos de paquete derivados de las citas (pacientes del legacy)."""
|
||
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:
|
||
if excluir_servicios and c.servicio_id.id in excluir_servicios:
|
||
continue
|
||
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'])
|
||
g['compra'] = False
|
||
g['id'] = None
|
||
g['state'] = 'active' if g['remaining'] else 'finished'
|
||
rows.append(g)
|
||
rows.sort(key=lambda r: (-r['remaining'], r['name']))
|
||
return rows
|
||
except Exception:
|
||
return []
|
||
|
||
# ============================================================
|
||
# Paquetes de sesiones
|
||
# ============================================================
|
||
def _paquete_to_dict(self, p):
|
||
servicio = p.service_id
|
||
return {
|
||
'id': p.id,
|
||
'name': p.name,
|
||
'code': p.code or '',
|
||
'service_id': servicio.id if servicio else None,
|
||
'service': servicio.name if servicio else '',
|
||
'sessions': p.sessions,
|
||
'price': p.price,
|
||
'price_per_session': round(p.price / p.sessions, 2) if p.sessions else 0.0,
|
||
'session_price': servicio.price if servicio else 0.0,
|
||
'duration_min': p.duration_min or (servicio.duration_min if servicio else 0),
|
||
'validity_days': p.validity_days or 0,
|
||
'notes': p.notes or '',
|
||
'color': p.color or '#1abc9c',
|
||
'genera_monedero': p.genera_monedero,
|
||
'active': p.active,
|
||
'sequence': p.sequence or 0,
|
||
'compras_count': p.compras_count,
|
||
}
|
||
|
||
def _paquete_compra_to_dict(self, c):
|
||
return {
|
||
'id': c.id,
|
||
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
|
||
'patient_id': c.partner_id.id,
|
||
'patient': c.partner_id.name or '',
|
||
'phone': c.partner_id.phone or '',
|
||
'paquete_id': c.paquete_id.id if c.paquete_id else None,
|
||
'paquete': c.paquete_id.name if c.paquete_id else '',
|
||
'service_id': c.service_id.id if c.service_id else None,
|
||
'service': c.service_id.name if c.service_id else '',
|
||
'sessions_total': c.sessions_total,
|
||
'sessions_used': c.sessions_used,
|
||
'sessions_remaining': c.sessions_remaining,
|
||
'price': c.price,
|
||
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||
'state': c.state,
|
||
'venta_id': c.venta_id.id if c.venta_id else None,
|
||
'venta': c.venta_id.name if c.venta_id else '',
|
||
'user_login': c.user_login or '',
|
||
}
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
@require_role('lectura')
|
||
def list_paquetes(self, **kw):
|
||
"""Catálogo de paquetes. `include_inactive=1` para ver los dados de baja."""
|
||
try:
|
||
domain = []
|
||
if not (kw.get('include_inactive') or '').strip():
|
||
domain.append(('active', '=', True))
|
||
if kw.get('search'):
|
||
s = kw['search']
|
||
domain += ['|', ('name', 'ilike', s), ('code', 'ilike', s)]
|
||
if kw.get('service_id'):
|
||
domain.append(('service_id', '=', int(kw['service_id'])))
|
||
rows = request.env['skeen.paquete'].sudo().with_context(
|
||
active_test=False).search(domain, order='sequence asc, name asc')
|
||
return json_response({'status': 'success', 'paquetes': [
|
||
self._paquete_to_dict(p) for p in rows]})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
|
||
methods=['POST'], csrf=False)
|
||
@require_role('admin')
|
||
def create_paquete(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)
|
||
if not data.get('service_id'):
|
||
return json_response({'status': 'error',
|
||
'message': 'El servicio es obligatorio'}, 400)
|
||
Paq = request.env['skeen.paquete'].sudo()
|
||
if Paq.search([('name', '=ilike', name)], limit=1):
|
||
return json_response({'status': 'error',
|
||
'message': f'Ya existe un paquete "{name}".'}, 409)
|
||
vals = {
|
||
'name': name,
|
||
'code': (data.get('code') or '').strip(),
|
||
'service_id': int(data['service_id']),
|
||
'sessions': int(data.get('sessions') or 1),
|
||
'price': float(data.get('price') or 0.0),
|
||
'duration_min': int(data.get('duration_min') or 0),
|
||
'validity_days': int(data.get('validity_days') or 0),
|
||
'notes': data.get('notes') or '',
|
||
'color': data.get('color') or '#1abc9c',
|
||
'genera_monedero': bool(data.get('genera_monedero', True)),
|
||
}
|
||
try:
|
||
p = Paq.create(vals)
|
||
except ValidationError as e:
|
||
return json_response({'status': 'error',
|
||
'message': e.args[0] if e.args else str(e)}, 400)
|
||
self._auditar('paquetes', 'skeen.paquete', 'create',
|
||
new_value=p.name, entity_label=p.name,
|
||
notes=f'{p.sessions} sesiones · {p.price}')
|
||
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)}, 201)
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes/<int:paquete_id>', type='http', auth='none',
|
||
methods=['PUT', 'PATCH'], csrf=False)
|
||
@require_role('admin')
|
||
def update_paquete(self, paquete_id, **kw):
|
||
try:
|
||
p = request.env['skeen.paquete'].sudo().with_context(active_test=False).browse(paquete_id)
|
||
if not p.exists():
|
||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||
data = _parse_json_body()
|
||
mapping = {
|
||
'name': 'name', 'code': 'code', 'notes': 'notes', 'color': 'color',
|
||
}
|
||
vals = {}
|
||
for key, field in mapping.items():
|
||
if key in data:
|
||
vals[field] = (data[key] or '').strip() if field != 'notes' else (data[key] or '')
|
||
if 'service_id' in data and data['service_id']:
|
||
vals['service_id'] = int(data['service_id'])
|
||
for key in ('sessions', 'duration_min', 'validity_days', 'sequence'):
|
||
if key in data:
|
||
vals[key] = int(data[key] or 0)
|
||
for key in ('price',):
|
||
if key in data:
|
||
vals[key] = float(data[key] or 0.0)
|
||
for key in ('genera_monedero', 'active'):
|
||
if key in data:
|
||
vals[key] = bool(data[key])
|
||
if 'active' in vals and vals['active'] != p.active:
|
||
self._auditar('paquetes', 'skeen.paquete', 'active',
|
||
old_value='activo' if p.active else 'inactivo',
|
||
new_value='activo' if vals['active'] else 'inactivo',
|
||
entity_label=p.name,
|
||
notes='Alta de baja' if not vals['active'] else 'Reactivación')
|
||
try:
|
||
p.write(vals)
|
||
except ValidationError as e:
|
||
return json_response({'status': 'error',
|
||
'message': e.args[0] if e.args else str(e)}, 400)
|
||
self._auditar('paquetes', 'skeen.paquete', 'edit',
|
||
new_value=', '.join(vals.keys()), entity_label=p.name)
|
||
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes/compras', type='http', auth='none',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
@require_role('lectura')
|
||
def list_paquetes_compras(self, **kw):
|
||
"""Paquetes vendidos con su saldo.
|
||
|
||
Filtros: patient_id, state (active|finished|expired|cancelled),
|
||
doctor_id, search (paciente o paquete), start/end por fecha de compra.
|
||
"""
|
||
try:
|
||
domain = []
|
||
if kw.get('patient_id'):
|
||
domain.append(('partner_id', '=', int(kw['patient_id'])))
|
||
if kw.get('state'):
|
||
domain.append(('state', '=', kw['state']))
|
||
elif not (kw.get('include_all') or '').strip():
|
||
domain.append(('state', '!=', 'cancelled'))
|
||
if kw.get('search'):
|
||
s = f"%{kw['search']}%"
|
||
domain += ['|', ('partner_id.name', 'ilike', s),
|
||
('paquete_id.name', 'ilike', s)]
|
||
if kw.get('start'):
|
||
domain.append(('date', '>=', kw['start']))
|
||
if kw.get('end'):
|
||
domain.append(('date', '<=', kw['end']))
|
||
if kw.get('doctor_id'):
|
||
domain.append(('line_ids.cita_id.doctor_id', '=', int(kw['doctor_id'])))
|
||
limit = min(max(int(kw.get('limit', 300) or 300), 1), 1000)
|
||
rows = request.env['skeen.paquete.compra'].sudo().search(
|
||
domain, order='date desc, id desc', limit=limit)
|
||
totales = {
|
||
'vigentes': sum(1 for c in rows if c.state == 'active'),
|
||
'completados': sum(1 for c in rows if c.state == 'finished'),
|
||
'vencidos': sum(1 for c in rows if c.state == 'expired'),
|
||
'sesiones_restantes': sum(c.sessions_remaining for c in rows
|
||
if c.state == 'active'),
|
||
'importe': sum(c.price for c in rows),
|
||
}
|
||
return json_response({'status': 'success',
|
||
'compras': [self._paquete_compra_to_dict(c) for c in rows],
|
||
'totales': totales})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/consumos',
|
||
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||
@require_role('lectura')
|
||
def paquete_compra_consumos(self, compra_id, **kw):
|
||
"""Bitácora de sesiones consumidas por un paquete (con su cita)."""
|
||
try:
|
||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||
if not c.exists():
|
||
return json_response({'status': 'error',
|
||
'message': 'Paquete no encontrado'}, 404)
|
||
filas = []
|
||
for mov in c.line_ids:
|
||
cita = mov.cita_id
|
||
filas.append({
|
||
'id': mov.id,
|
||
'date': mov.date.strftime('%Y-%m-%d %H:%M') if mov.date else None,
|
||
'state': mov.state,
|
||
'cita_id': cita.id if cita else None,
|
||
'cita_fecha': cita.date.strftime('%Y-%m-%d') if (cita and cita.date) else None,
|
||
'cita_hora': cita.time_str if cita else '',
|
||
'doctor': cita.doctor_id.name if (cita and cita.doctor_id) else '',
|
||
'estado_cita': cita.state if cita else '',
|
||
})
|
||
filas.sort(key=lambda f: (f['cita_fecha'] or '', f['cita_hora'] or ''))
|
||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c),
|
||
'consumos': filas})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>', type='http',
|
||
auth='none', methods=['PUT', 'PATCH'], csrf=False)
|
||
@require_role('recepcion')
|
||
def update_paquete_compra(self, compra_id, **kw):
|
||
"""Ajuste manual del saldo (sesiones compradas / usadas)."""
|
||
try:
|
||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||
if not c.exists():
|
||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||
data = _parse_json_body()
|
||
before = f"{c.sessions_total}/{c.sessions_used}"
|
||
try:
|
||
c.action_adjust_sessions(data.get('sessions_total'), data.get('sessions_used'))
|
||
except (ValueError, TypeError):
|
||
return json_response({'status': 'error',
|
||
'message': 'Las sesiones deben ser números enteros'}, 400)
|
||
self._auditar('paquetes', 'skeen.paquete.compra', 'sessions',
|
||
old_value=before,
|
||
new_value=f"{c.sessions_total}/{c.sessions_used}",
|
||
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
|
||
notes='Ajuste manual de saldo')
|
||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/anular',
|
||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||
@require_role('gerencia')
|
||
def anular_paquete_compra(self, compra_id, **kw):
|
||
try:
|
||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||
if not c.exists():
|
||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||
data = _parse_json_body()
|
||
motivo = (data.get('reason') or '').strip()
|
||
if not motivo:
|
||
return json_response({'status': 'error',
|
||
'message': 'Indica el motivo de la anulación'}, 400)
|
||
c.action_cancel()
|
||
c.write({'sessions_total': 0})
|
||
self._auditar('paquetes', 'skeen.paquete.compra', 'state',
|
||
old_value='activo', new_value='anulado',
|
||
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
|
||
notes=motivo)
|
||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
|
||
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,
|
||
}
|
||
|
||
@http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||
@require_role('lectura')
|
||
def expedientes_recientes(self, **kw):
|
||
"""Pacientes con visitas recientes (últimos 30 días por default)"""
|
||
try:
|
||
domain = [('is_patient', '=', True)]
|
||
recencia = kw.get('recencia') or ''
|
||
if recencia == '6m':
|
||
corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
||
domain.append(('last_visit', '>=', corte))
|
||
elif recencia == '6m_plus':
|
||
corte = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
||
domain += ['|', ('last_visit', '=', False), ('last_visit', '<', corte)]
|
||
else:
|
||
date_from = kw.get('date_from') or (date.today() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||
if date_from != 'todas':
|
||
domain.append(('last_visit', '>=', date_from))
|
||
else:
|
||
domain.append(('last_visit', '!=', False))
|
||
if kw.get('vip') == '1':
|
||
domain.append(('is_vip', '=', True))
|
||
if kw.get('doctor_id'):
|
||
domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id'))))
|
||
if kw.get('search'):
|
||
s = kw.get('search')
|
||
domain += ['|', ('name', 'ilike', s), ('phone', 'ilike', s)]
|
||
page = max(int(kw.get('page', 1) or 1), 1)
|
||
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
|
||
Partner = request.env['res.partner'].sudo()
|
||
total = Partner.search_count(domain)
|
||
offset = (page - 1) * page_size
|
||
pacientes = Partner.search(domain,
|
||
order='last_visit asc' if recencia == '6m_plus' else 'last_visit desc',
|
||
limit=page_size, offset=offset)
|
||
# Conteo de adjuntos por paciente y tipo en un solo read_group
|
||
counts = {}
|
||
if pacientes:
|
||
grupos = request.env['skeen.patient.adjunto'].sudo().read_group(
|
||
[('partner_id', 'in', pacientes.ids)],
|
||
['partner_id', 'kind'], ['partner_id', 'kind'], lazy=False)
|
||
for g in grupos:
|
||
counts.setdefault(g['partner_id'][0], {})[g['kind']] = g['__count']
|
||
items = [{
|
||
'id': p.id,
|
||
'patient_id': p.patient_id,
|
||
'name': p.name,
|
||
'phone': p.phone or '',
|
||
'age': p.age,
|
||
'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None,
|
||
'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None,
|
||
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
|
||
'internal_notes': p.internal_notes or '',
|
||
'expediente_count': counts.get(p.id, {}).get('expediente', 0),
|
||
'imagenes_count': counts.get(p.id, {}).get('imagen', 0),
|
||
'has_photo': bool(p.image_1920),
|
||
'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo',
|
||
} for p in pacientes]
|
||
return json_response({
|
||
'status': 'success',
|
||
'expedientes': items,
|
||
'total': total,
|
||
'page': page,
|
||
'page_size': page_size,
|
||
'total_pages': (total + page_size - 1) // page_size,
|
||
})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
# ============================================================
|
||
# Adjuntos de paciente (expediente escaneado / galería)
|
||
# ============================================================
|
||
def _patient_adjunto_to_dict(self, a):
|
||
return {
|
||
'id': a.id,
|
||
'kind': a.kind,
|
||
'name': a.name or '',
|
||
'mimetype': a.mimetype or '',
|
||
'notes': a.notes or '',
|
||
'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')
|
||
# Por defecto solo el personal activo: así desaparecen de la agenda,
|
||
# del selector de médico de pacientes y del selector de ventas.
|
||
# La pantalla de Médicos pide include_inactive=1 para poder reactivar.
|
||
domain = [] if str(kw.get('include_inactive')).lower() in ('1', 'true') else [('active', '=', True)]
|
||
doctors = request.env['hr.employee'].sudo().with_context(active_test=False).search(
|
||
domain, order='active desc, name', limit=300)
|
||
# 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,
|
||
'active': d.active,
|
||
'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 'active' in data:
|
||
# Desactivar oculta al médico de la agenda, de la asignación de
|
||
# pacientes y del selector de ventas; no borra su historial.
|
||
vals['active'] = bool(data.get('active'))
|
||
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 ValidationError as e:
|
||
return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400)
|
||
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>', type='http',
|
||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||
@require_role('recepcion')
|
||
@forbid_roles('marketing')
|
||
def update_sale(self, sale_id, **kw):
|
||
"""Corrige un ticket ya emitido (gerencia / administrador).
|
||
|
||
Recepción a veces cobra mal — un servicio equivocado, una cantidad o un
|
||
precio alzado, falta un concepto. Antes la única salida era cancelar y
|
||
rehacer, que revierte el cobro completo. Aquí se modifica el ticket
|
||
conservando el pago registrado.
|
||
|
||
`lines` reemplaza la lista de conceptos (con `id` para editar, sin `id`
|
||
para alta; las que no aparecen se eliminan). Se requiere `motivo` y
|
||
queda en el chatter del ticket y en la bitácora de auditoría.
|
||
"""
|
||
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()
|
||
motivo = (data.get('motivo') or data.get('reason') or '').strip()
|
||
if not motivo:
|
||
return json_response({'status': 'error',
|
||
'message': 'Indica el motivo de la corrección'}, 400)
|
||
antes = {'total': sale.total, 'conceptos': len(sale.line_ids),
|
||
'state': sale.state}
|
||
result = sale.action_edit(
|
||
reason=motivo,
|
||
lines=data.get('lines'),
|
||
discount=data.get('discount'),
|
||
tax=data.get('tax'),
|
||
notes=data.get('notes'),
|
||
date=data.get('date'),
|
||
editor=request.frontend_user.login,
|
||
)
|
||
self._auditar('ventas', f'venta:{sale.id}', 'edicion_ticket',
|
||
old_value='%.2f · %d conceptos · %s' % (
|
||
antes['total'], antes['conceptos'], antes['state']),
|
||
new_value='%.2f · %d conceptos · %s' % (
|
||
sale.total, len(sale.line_ids), sale.state),
|
||
entity_label=f'{sale.name} · {sale.partner_id.name}',
|
||
notes=motivo)
|
||
return json_response({'status': 'success',
|
||
'sale': self._sale_con_pagos(sale),
|
||
'result': result})
|
||
except ValidationError as e:
|
||
return json_response({'status': 'error',
|
||
'message': e.args[0] if e.args else 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/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_con_pagos(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_con_pagos(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_con_pagos(self, v):
|
||
# _sale_to_dict + los pagos ligados: el modal de Tickets los muestra
|
||
# y tras una corrección hay que devolverlos o parecería que el cobro
|
||
# se perdió.
|
||
pagos = [self._payment_to_dict(p)
|
||
for p in request.env['skeen.pago'].sudo().search([('venta_id', '=', v.id)])]
|
||
return {**self._sale_to_dict(v), 'pagos': pagos}
|
||
|
||
def _sale_to_dict(self, v):
|
||
return {
|
||
'id': v.id,
|
||
'name': v.name,
|
||
'patient_id': v.partner_id.id,
|
||
'patient': v.partner_id.name,
|
||
'patient_phone': v.partner_phone or '',
|
||
'doctor': next((l.prescribed_by_id.name for l in v.line_ids
|
||
if l.prescribed_by_id), ''),
|
||
'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,
|
||
'paquete_id': line.paquete_id.id if line.paquete_id else None,
|
||
'paquete': line.paquete_id.name if line.paquete_id else '',
|
||
'sesiones': line.paquete_id.sessions if line.paquete_id else 0,
|
||
} 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
|
||
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
|
||
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)
|
||
paquete_id = int(l.get('paquete_id', 0) or 0)
|
||
if paquete_id and not service_id:
|
||
# Una línea de paquete también consume servicio (el del paquete)
|
||
# para que el reporte de comisiones no se rompa.
|
||
service_id = int(request.env['skeen.paquete'].sudo().browse(
|
||
paquete_id).service_id.id 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,
|
||
'paquete_id': paquete_id or False,
|
||
}))
|
||
if paquete_id:
|
||
paquete_lines.append((paquete_id, qty, price))
|
||
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()
|
||
|
||
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
|
||
# las compras a las líneas reales de la venta para poder revertirlas.
|
||
compras_creadas = []
|
||
if paquete_lines:
|
||
Compra = request.env['skeen.paquete.compra'].sudo()
|
||
lineas_venta = venta.line_ids.filtered(lambda ln: ln.paquete_id)
|
||
for line in lineas_venta:
|
||
for _i in range(int(line.quantity or 1)):
|
||
compras_creadas.append(Compra.create({
|
||
'partner_id': partner.id,
|
||
'paquete_id': line.paquete_id.id,
|
||
'venta_id': venta.id,
|
||
'sale_line_id': line.id,
|
||
'date': venta.date or fields.Date.today(),
|
||
'sessions_total': line.paquete_id.sessions,
|
||
'price': line.price_unit,
|
||
'user_login': cobrador,
|
||
}))
|
||
|
||
# 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
|
||
recibido = 0.0
|
||
if restante > 0:
|
||
received = float(data.get('amount_received', restante) or restante)
|
||
recibido = received
|
||
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,
|
||
'recibido': recibido,
|
||
'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,
|
||
'packages': [{
|
||
'id': c.id,
|
||
'paquete': c.paquete_id.name,
|
||
'service_id': c.service_id.id if c.service_id else None,
|
||
'sessions_total': c.sessions_total,
|
||
'sessions_remaining': c.sessions_remaining,
|
||
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||
} for c in compras_creadas],
|
||
}, 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,
|
||
# Los módulos disponibles salen de la selección del modelo para
|
||
# que el filtro del frontend nunca se desincronice.
|
||
'modulos': [{'value': k, 'label': v} for k, v in
|
||
request.env['skeen.config.audit']._fields['module'].selection],
|
||
'total_pages': (total + page_size - 1) // page_size,
|
||
})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
# ============================================================
|
||
# Brand de la clínica (nombre, logos, pie) — white-label
|
||
# ============================================================
|
||
BRAND_KEY = 'skeen.clinic.brand'
|
||
|
||
def _brand_dict(self):
|
||
"""Brand vigente: lo guardado en ir.config_parameter o defaults SKEEN.
|
||
|
||
Este endpoint es lo que permite desplegar la plataforma para otra
|
||
clínica sin tocar código: la instancia nueva solo cambia este
|
||
parámetro y sube sus logos. Los campos:
|
||
nombre - nombre comercial (login, títulos, mensajes, tickets)
|
||
logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
|
||
logo_oscuro - logo para fondos claros (tema HomeNest)
|
||
pie - línea del pie (login / documentos impresos)
|
||
"""
|
||
defaults = {
|
||
'nombre': 'SKEEN Derma Experts',
|
||
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
|
||
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
|
||
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
|
||
}
|
||
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
|
||
if raw:
|
||
try:
|
||
guardado = json.loads(raw)
|
||
if isinstance(guardado, dict):
|
||
defaults.update({k: str(v) for k, v in guardado.items()
|
||
if k in defaults and v is not None and str(v).strip()})
|
||
except Exception:
|
||
pass # JSON roto: se sirven los defaults
|
||
return defaults
|
||
|
||
@http.route('/skeen/frontend/v1/brand', type='http',
|
||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||
def get_brand(self, **kw):
|
||
"""Brand de la clínica. PÚBLICO: el login (sin sesión) lo necesita
|
||
para pintar nombre/logo de la clínica antes de autenticar. Solo expone
|
||
nombre y URLs de logos, nada sensible."""
|
||
try:
|
||
return json_response({'status': 'success', 'brand': self._brand_dict()})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/brand', type='http',
|
||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||
@require_role('admin')
|
||
def put_brand(self, **kw):
|
||
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
|
||
try:
|
||
data = _parse_json_body()
|
||
brand = data.get('brand')
|
||
if not isinstance(brand, dict):
|
||
return json_response({'status': 'error',
|
||
'message': 'brand debe ser un objeto'}, 400)
|
||
antes = self._brand_dict()
|
||
limpio = {k: str(brand[k]).strip() for k in
|
||
('nombre', 'logo_claro', 'logo_oscuro', 'pie')
|
||
if brand.get(k) is not None and str(brand.get(k)).strip()}
|
||
request.env['ir.config_parameter'].sudo().set_param(
|
||
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
|
||
nuevo = self._brand_dict()
|
||
self._auditar('clinica', 'brand', 'brand',
|
||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||
entity_label='Identidad de la clínica (brand)',
|
||
notes=(data.get('motivo') or 'actualizado').strip())
|
||
return json_response({'status': 'success', 'brand': nuevo,
|
||
'message': 'Identidad de la clínica actualizada'})
|
||
except Exception as e:
|
||
request.env.cr.rollback()
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
# ============================================================
|
||
# Diseño de tickets (Configuración › Tickets)
|
||
# ============================================================
|
||
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||
@require_role('lectura')
|
||
def get_ticket_config(self, **kw):
|
||
"""Layout + perfil de la clínica + logo, para el impresor del POS.
|
||
|
||
Lectura abierta a cualquier rol autenticado: quien imprime (recepción,
|
||
farmacia, gerencia) necesita el mismo diseño sin depender de admin.
|
||
"""
|
||
try:
|
||
return json_response({'status': 'success',
|
||
**request.env['skeen.ticket.config'].sudo().config_completa()})
|
||
except Exception as e:
|
||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||
|
||
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||
@require_role('admin')
|
||
def put_ticket_config(self, **kw):
|
||
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
|
||
try:
|
||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||
data = _parse_json_body()
|
||
layout = data.get('layout')
|
||
if layout is None:
|
||
return json_response({'status': 'error',
|
||
'message': 'Falta el diseño del ticket'}, 400)
|
||
Config = request.env['skeen.ticket.config'].sudo()
|
||
antes = Config.get_layout()
|
||
nuevo = Config.set_layout(layout)
|
||
self._auditar('clinica', 'ticket:layout', 'layout',
|
||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||
entity_label='Diseño del ticket impreso',
|
||
notes=(data.get('motivo') or 'actualizado').strip())
|
||
return json_response({'status': 'success', 'layout': nuevo,
|
||
'message': 'Diseño del ticket actualizado'})
|
||
except ValidationError as e:
|
||
return json_response({'status': 'error',
|
||
'message': e.args[0] if e.args else 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/ticket_config/clinica', type='http',
|
||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||
@require_role('admin')
|
||
def put_ticket_clinica(self, **kw):
|
||
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
|
||
try:
|
||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||
data = _parse_json_body()
|
||
Config = request.env['skeen.ticket.config'].sudo()
|
||
clinica = None
|
||
if data.get('clinica') is not None:
|
||
antes = Config.get_clinica()
|
||
clinica = Config.set_clinica(data['clinica'])
|
||
self._auditar('clinica', 'ticket:clinica', 'perfil',
|
||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||
new_value=json.dumps(clinica, ensure_ascii=False)[:500],
|
||
entity_label='Datos de la clínica en el ticket',
|
||
notes=(data.get('motivo') or 'actualizado').strip())
|
||
if 'logo' in data:
|
||
Config.set_logo(data.get('logo') or '')
|
||
self._auditar('clinica', 'ticket:logo', 'logo',
|
||
new_value='presente' if (data.get('logo') or '') else 'borrado',
|
||
entity_label='Logo del ticket')
|
||
return json_response({'status': 'success',
|
||
**Config.config_completa()})
|
||
except ValidationError as e:
|
||
return json_response({'status': 'error',
|
||
'message': e.args[0] if e.args else 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/ticket_config/reset', type='http',
|
||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||
@require_role('admin')
|
||
def reset_ticket_config(self, **kw):
|
||
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
|
||
try:
|
||
if request.frontend_user.role != 'admin':
|
||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||
Config = request.env['skeen.ticket.config'].sudo()
|
||
layout = Config.reset_layout()
|
||
self._auditar('clinica', 'ticket:layout', 'layout',
|
||
new_value='por omisión',
|
||
entity_label='Diseño del ticket impreso',
|
||
notes='restaurado al formato original')
|
||
return json_response({'status': 'success', 'layout': layout})
|
||
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),
|
||
'price': float(data.get('price', 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', 'price', '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', 'price'):
|
||
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
|