# -*- coding: utf-8 -*- from odoo import http from odoo.exceptions import ValidationError from odoo.http import request, Response import base64 import calendar 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, require_perm from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles from ..roles import AGENDA_PERM_KEYS, role_keys, role_selection from ..permissions import (PERMISSION_GROUPS, PERM_KEYS, PERM_LABELS, ROLE_PERMS_KEY, has_perm, all_roles_perms, default_role_perms, resolve_role_perms, set_role_perms) from odoo.addons.skeen_citas.clinic_settings import ( agenda_bounds as clinic_agenda_bounds, get_settings as get_clinic_settings, set_settings as set_clinic_settings, get_impuestos, get_recursos, get_compras, get_lealtad, get_propinas, get_direccion, ) from odoo.addons.skeen_monedero.lealtad import ( nivel_por_codigo as lealtad_nivel_por_codigo, ) from odoo.addons.skeen_citas.glosario import ( GLOSARIO_KEYS as GLOSARIO_KEY_SET, get_config as get_glosario_config, list_packs as list_glosario_packs, resolve as resolver_glosario, set_config as set_glosario_config, ) from odoo.addons.skeen_citas.catalogs import slugify as catalog_slugify from odoo.addons.skeen_citas.catalogs import unique_slug as catalog_unique_slug 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. Son solo el respaldo: el horario real # sale de los ajustes de la clínica (clinic-settings). Sin configurar se # usan estos valores, idénticos al comportamiento de siempre. 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 _agenda_bounds(self): """(inicio, fin) de la agenda en horas float, según los ajustes.""" try: return clinic_agenda_bounds(request.env) except Exception: return self.AGENDA_START, self.AGENDA_END def _agenda_horario_txt(self): """'8:00 a 19:00' para los mensajes de validación.""" inicio, fin = self._agenda_bounds() return '%s a %s' % (self._fmt_horas(inicio), self._fmt_horas(fin)) def _valid_agenda_time(self, value): """Valida hora de inicio dentro del horario de agenda configurado.""" try: t = float(value) except (TypeError, ValueError): return False inicio, fin = self._agenda_bounds() return inicio <= t < fin # ------------------------------------------------------------------ # 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') @require_perm('config.usuarios') 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') @require_perm('config.usuarios') 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(request.env): 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/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('config.usuarios') 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(request.env): 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/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') @require_perm('config.usuarios') 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': r.code, 'label': r.name, 'active': r.active, 'rank': r.rank} for r in request.env['skeen.role'].sudo().with_context( active_test=False).search([], order='sequence, name')] or [{'key': k, 'label': l, 'active': True, 'rank': 0} 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') @require_perm('config.roles') 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) valid_roles = role_keys(request.env) 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 valid_roles 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) # ============================================================ # Roles configurables (skeen.role) # ============================================================ # 'admin' y 'lectura' no se pueden desactivar: sin admin nadie administra y # sin lectura no queda el rol base de solo consulta. ROLES_PROTEGIDOS = ('admin', 'lectura') def _role_to_dict(self, r): return { 'id': r.id, 'code': r.code, 'name': r.name, 'rank': r.rank, 'sequence': r.sequence, 'active': r.active, 'is_system': r.is_system, 'description': r.description or '', } @http.route('/skeen/frontend/v1/roles', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_roles(self, **kw): """Roles configurados. ?include_inactive=1 trae también los inactivos.""" try: incluir = kw.get('include_inactive') in ('1', 'true', 'True') Role = request.env['skeen.role'].sudo() if incluir: Role = Role.with_context(active_test=False) recs = Role.search([], order='sequence, name') return json_response({'status': 'success', 'roles': [self._role_to_dict(r) for r in recs], 'total': len(recs)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/roles', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') @require_perm('config.roles') def create_role(self, **kw): """Crea un rol. El `code` se genera como slug único del nombre.""" try: if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) Role = request.env['skeen.role'].sudo() codigo = (data.get('code') or '').strip() if codigo: codigo = catalog_slugify(codigo) or codigo if Role.with_context(active_test=False).search_count([('code', '=', codigo)]): return json_response({ 'status': 'error', 'message': 'Ya existe un rol con el código %s' % codigo}, 409) else: codigo = catalog_unique_slug(Role, name) try: rank = int(data.get('rank', 50)) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'El rango debe ser numérico'}, 400) try: sequence = int(data.get('sequence') or 10) except (TypeError, ValueError): sequence = 10 rol = Role.create({ 'name': name, 'code': codigo, 'rank': rank, 'sequence': sequence, 'active': bool(data.get('active', True)), 'description': data.get('description') or '', 'is_system': False, }) self._auditar('roles', 'role:%s' % rol.id, 'crear', new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], entity_label='Rol %s' % rol.name, notes=(data.get('motivo') or 'creado').strip()) return json_response({'status': 'success', 'role': self._role_to_dict(rol)}, 201) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/roles/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.roles') def update_role(self, role_id, **kw): """Renombra / ajusta rango, secuencia, estado y descripción de un rol. El `code` NUNCA se cambia: es el valor guardado en los usuarios. """ try: if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) rol = request.env['skeen.role'].sudo().with_context( active_test=False).browse(role_id) if not rol.exists(): return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404) data = _parse_json_body() vals = {} if 'name' in data and str(data.get('name') or '').strip(): vals['name'] = str(data['name']).strip() if 'description' in data: vals['description'] = data.get('description') or '' if 'rank' in data: try: vals['rank'] = int(data['rank']) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'El rango debe ser numérico'}, 400) if 'sequence' in data: try: vals['sequence'] = int(data['sequence']) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) if 'active' in data: activo = bool(data['active']) if not activo and rol.code in self.ROLES_PROTEGIDOS: return json_response({ 'status': 'error', 'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400) vals['active'] = activo if not vals: return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) rol.write(vals) self._auditar('roles', 'role:%s' % rol.id, 'editar', new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], entity_label='Rol %s' % rol.name, notes=(data.get('motivo') or 'actualizado').strip()) return json_response({'status': 'success', 'role': self._role_to_dict(rol)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/roles/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') @require_perm('config.roles') def delete_role(self, role_id, **kw): """Desactiva un rol (nunca se borra: los usuarios lo referencian). Los roles de sistema no se pueden eliminar (400). """ try: if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) rol = request.env['skeen.role'].sudo().with_context( active_test=False).browse(role_id) if not rol.exists(): return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404) if rol.is_system: return json_response({ 'status': 'error', 'message': ('"%s" es un rol del sistema: no se puede eliminar, ' 'solo desactivar (si aplica) o renombrar.' % rol.name)}, 400) if rol.code in self.ROLES_PROTEGIDOS: return json_response({ 'status': 'error', 'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400) en_uso = request.env['skeen.frontend.user'].sudo().search_count( [('role', '=', rol.code)]) rol.write({'active': False}) self._auditar('roles', 'role:%s' % rol.id, 'desactivar', old_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], entity_label='Rol %s' % rol.name, notes='desactivado (%d usuarios)' % en_uso) mensaje = 'Rol desactivado.' if en_uso: mensaje = ('El rol tiene %d usuario(s); quedó desactivado y sus ' 'usuarios conservan el rol.' % en_uso) return json_response({'status': 'success', 'role': self._role_to_dict(rol), 'en_uso': en_uso, 'message': mensaje}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Permisos por acción (catálogo + por rol) # ============================================================ def _perm_catalogo(self): # Con `compras.activo=false` el grupo de permisos de Compras no se # muestra en el editor de roles: apagar el módulo no debe cambiar # ninguna pantalla existente. try: ocultar = set() if get_compras(request.env).get('activo') else {'compras'} except Exception: ocultar = set() return [{'grupo': g, 'label': label, 'permisos': [{'key': k, 'label': l} for k, l in perms]} for g, label, perms in PERMISSION_GROUPS if g not in ocultar] @http.route('/skeen/frontend/v1/permisos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.roles') def permisos_get(self, **kw): """Catálogo de permisos + permisos vigentes por rol + defaults.""" try: if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) Role = request.env['skeen.role'].sudo().with_context(active_test=False) return json_response({ 'status': 'success', 'catalogo': self._perm_catalogo(), 'por_rol': all_roles_perms(request.env), 'defaults': default_role_perms(), 'roles': [{'code': r.code, 'name': r.name, 'rank': r.rank, 'active': r.active, 'is_system': r.is_system} for r in Role.search([], order='sequence, name')], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/permisos', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.roles') def permisos_put(self, **kw): """Guarda permisos de uno o varios roles. Body: {role_code, permisos:{clave:bool}} o {por_rol:{role_code:{...}}}. """ try: if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() validos = role_keys(request.env) guardados = 0 if isinstance(data.get('por_rol'), dict): por_rol = data['por_rol'] elif data.get('role_code'): por_rol = {data['role_code']: data.get('permisos') or {}} else: return json_response({'status': 'error', 'message': 'Se espera role_code+permisos o por_rol'}, 400) for code, perms in por_rol.items(): if code not in validos: return json_response({'status': 'error', 'message': 'Rol inválido: %s' % code}, 400) if not isinstance(perms, dict): return json_response({'status': 'error', 'message': 'permisos de %s debe ser un objeto' % code}, 400) if set_role_perms(request.env, code, perms): guardados += 1 self._auditar('roles', 'role_perms', 'permisos', new_value=json.dumps(por_rol, ensure_ascii=False)[:2000], entity_label='Permisos por rol', notes='%d rol(es)' % guardados) return json_response({'status': 'success', 'por_rol': all_roles_perms(request.env), 'guardados': guardados}) except Exception as e: request.env.cr.rollback() 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), } # Recursos físicos (cabina, sala, equipo). Solo se atienden cuando la # clínica tiene el módulo activo; con recursos.activo=False se ignora. cfg_recursos = get_recursos(request.env) recurso_ids = self._recursos_payload(data) if cfg_recursos['activo'] else [] if cfg_recursos['activo']: if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1: return json_response({'status': 'error', 'message': 'Solo se permite un recurso por cita'}, 400) if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids: return json_response({ 'status': 'error', 'message': 'Selecciona al menos un recurso para agendar la cita'}, 400) base_vals['recurso_ids'] = [(6, 0, recurso_ids)] 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 (%s)' % self._agenda_horario_txt()}, 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 (%s)' % self._agenda_horario_txt()}, 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), **self._recurso_vals_cita(data), }) 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/', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_appointment(self, appointment_id, **kw): try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') @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} cfg_recursos = get_recursos(request.env) if cfg_recursos['activo'] and 'recurso_ids' in data: recurso_ids = self._recursos_payload(data) if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1: return json_response({'status': 'error', 'message': 'Solo se permite un recurso por cita'}, 400) if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids: return json_response({ 'status': 'error', 'message': 'Selecciona al menos un recurso para agendar la cita'}, 400) vals['recurso_ids'] = [(6, 0, recurso_ids)] 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 (%s)' % self._agenda_horario_txt()}, 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/', 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//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//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//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//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 if b.doctor_id else None, 'doctor': b.doctor_id.name if b.doctor_id else None, 'recurso_ids': b.recurso_ids.ids, 'recursos': [{'id': r.id, 'name': r.name, 'code': r.code, 'color': r.color or ''} for r in b.recurso_ids], '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)) 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) # Recursos (opcional): bloquear una cabina/sala/equipo sin médico. recurso_ids = self._recursos_payload(data) if get_recursos(request.env)['activo'] else [] recursos = request.env['skeen.recurso'].sudo().browse(recurso_ids).exists() if not doctores and not recursos: return json_response( {'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400) 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: agenda_ini, agenda_fin = self._agenda_bounds() if time_from < agenda_ini - 1e-6 or time_to > agenda_fin + 1e-6: return json_response( {'status': 'error', 'message': 'El rango de horas debe estar entre %s' % self._agenda_horario_txt()}, 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() base = { '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, } filas = [] # Un bloqueo por médico (sin recurso) y uno por recurso (sin médico): # seleccionar médicos y recursos bloquea la unión de ambos. for doc in doctores: for f in fechas: filas.append({**base, 'date': f.strftime('%Y-%m-%d'), 'doctor_id': doc.id}) for rec in recursos: for f in fechas: filas.append({**base, 'date': f.strftime('%Y-%m-%d'), 'recurso_ids': [(6, 0, [rec.id])]}) creados = Bloqueo.with_context( skeen_skip_bloqueo_citas=bool(data.get('force')), skeen_skip_recursos_solape=bool(data.get('force')), ).create(filas) etiqueta = ', '.join(doctores.mapped('name') + recursos.mapped('name')) self._auditar('agenda', 'skeen.cita.bloqueo', 'crear', new_value='%s bloqueo(s)' % len(creados), entity_label=etiqueta) 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/', 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: vals['doctor_id'] = int(data['doctor_id']) if data.get('doctor_id') else False 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 'recurso_ids' in data and get_recursos(request.env)['activo']: vals['recurso_ids'] = [(6, 0, self._recursos_payload(data))] if not vals: return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) if not vals.get('doctor_id') and 'recurso_ids' not in vals and not bloqueo.recurso_ids: return json_response( {'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400) bloqueo.with_context( skeen_skip_bloqueo_citas=bool(data.get('force')), skeen_skip_recursos_solape=bool(data.get('force')), ).write(vals) etiqueta = bloqueo.doctor_id.name if bloqueo.doctor_id else \ ', '.join(bloqueo.recurso_ids.mapped('name')) self._auditar('agenda', 'skeen.cita.bloqueo', 'editar', entity_label=etiqueta, 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/', 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 if bloqueo.doctor_id else ', '.join(bloqueo.recurso_ids.mapped('name')) or 'bloqueo', 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/', 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 _sala_codigo(self, data): """Código de sala válido (de los recursos) o False si viene vacío/ajeno.""" value = data.get('sala') if not value: return False validos = [c for c, _l in request.env['skeen.lista.espera']._get_sala_selection()] return value if value in validos else False 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': r.sala_label(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': self._sala_codigo(data), '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/', 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'] = self._sala_codigo(data) if vals['sala']: record._log('Sala: %s' % record.sala_label(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/', 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, # Anticipo de reserva en línea (distinguir "esperando anticipo"). 'anticipo_estado': cita.anticipo_estado or 'ninguno', 'anticipo_estado_label': dict(cita._fields['anticipo_estado'].selection).get( cita.anticipo_estado, cita.anticipo_estado), 'anticipo_monto': cita.anticipo_monto or 0.0, # 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, 'recurso_ids': cita.recurso_ids.ids, 'recursos': [self._recurso_to_dict(r) for r in cita.recurso_ids], 'branch': cita.branch, 'medium': cita.medium, 'booking_source': cita.booking_source or 'internal', '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/', 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/', 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//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//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//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//insumos/', 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//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/', 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//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 )""" 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') @require_perm('pacientes.exportar') 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') @require_perm('pacientes.crear') 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/', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_patient(self, patient_id, **kw): try: patient = request.env['res.partner'].sudo().browse(patient_id) if not patient.exists(): return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404) return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') @require_perm('pacientes.editar') 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//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 )""" 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//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//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/', 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//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/', 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//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//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 cfg_lea = get_lealtad(request.env) valor = float(cfg_lea.get('valor_punto') or 1.0) return json_response({ 'status': 'success', 'patient_id': partner.id, 'patient': partner.name, 'wallet_id': monedero.id if monedero else None, 'points': saldo, 'valor_punto': valor, 'equivalent_mxn': float(saldo) * valor, 'equivalent_usd': round(saldo * valor / rate, 2) if rate else None, 'rate': rate, 'nivel': self._lealtad_nivel_dict(cfg_lea, partner), '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) # ============================================================ # Lealtad: puntos, niveles y reportes # ============================================================ def _lealtad_nivel_dict(self, cfg, partner): nivel = lealtad_nivel_por_codigo(cfg, partner.lealtad_nivel_codigo) return { 'codigo': partner.lealtad_nivel_codigo or '', 'nombre': (nivel or {}).get('nombre', '') or '', 'descuento_pct': float((nivel or {}).get('descuento_pct') or 0.0), 'factor_puntos': float((nivel or {}).get('factor_puntos') or 1.0), 'color': (nivel or {}).get('color', '') or '', 'desde': partner.lealtad_nivel_desde.strftime('%Y-%m-%d') if partner.lealtad_nivel_desde else None, 'manual': bool(partner.lealtad_manual), 'revisar': partner.lealtad_nivel_revisar.strftime('%Y-%m-%d') if partner.lealtad_nivel_revisar else None, } @http.route('/skeen/frontend/v1/patients//lealtad', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('lealtad.ver') def patient_lealtad(self, patient_id, **kw): """Nivel VIP, saldo de puntos, valor y gasto del paciente.""" 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) cfg = get_lealtad(request.env) monedero = self._monedero_de(partner, crear=False) saldo = monedero.points if monedero else 0 valor = float(cfg.get('valor_punto') or 1.0) return json_response({ 'status': 'success', 'patient_id': partner.id, 'patient': partner.name, 'activo': bool(cfg.get('activo')), 'modo': cfg.get('modo'), 'valor_punto': valor, 'puntos': saldo, 'equivalente_mxn': round(saldo * valor, 2), 'gasto_ventana': partner.lealtad_gasto, 'nivel': self._lealtad_nivel_dict(cfg, partner), 'niveles': cfg.get('niveles') or [], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients//lealtad/recalcular', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('lealtad.ajustar') def patient_lealtad_recalcular(self, patient_id, **kw): """Recalcula el nivel VIP (respeta la asignación manual vigente).""" 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) cfg = get_lealtad(request.env) partner._lealtad_recalcular(cfg) return json_response({'status': 'success', 'nivel': self._lealtad_nivel_dict(cfg, partner), 'gasto_ventana': partner.lealtad_gasto}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients//lealtad', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('gerencia') @require_perm('lealtad.nivel') def patient_lealtad_set(self, patient_id, **kw): """Asigna el nivel a mano con fecha de revisión (no se sobreescribe).""" 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) cfg = get_lealtad(request.env) data = _parse_json_body() codigo = (data.get('codigo') or '').strip() if codigo: nivel = lealtad_nivel_por_codigo(cfg, codigo) if not nivel: return json_response({'status': 'error', 'message': 'Nivel desconocido'}, 400) revisar = (data.get('revisar') or '').strip() if revisar: try: revisar_d = fields.Date.to_date(revisar) except Exception: return json_response({'status': 'error', 'message': 'Fecha de revisión inválida'}, 400) else: revisar_d = False usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '' partner.write({ 'lealtad_nivel_codigo': codigo or False, 'lealtad_nivel_desde': fields.Date.today() if codigo else False, 'lealtad_manual': True, 'lealtad_nivel_revisar': revisar_d, }) self._auditar('lealtad', f'paciente:{partner.id}', 'nivel_manual', False, codigo, entity_label=partner.name, notes='Asignación manual de nivel (%s)' % usuario) return json_response({'status': 'success', 'nivel': self._lealtad_nivel_dict(cfg, partner)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients//points/adjust', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('lealtad.ajustar') def patient_points_adjust(self, patient_id, **kw): """Ajuste manual del saldo de puntos (con motivo, para auditoría).""" 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: delta = int(data.get('points') or 0) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'Puntos inválidos'}, 400) motivo = (data.get('motivo') or '').strip() if not delta: return json_response({'status': 'error', 'message': 'Indica los puntos a ajustar'}, 400) if not motivo: return json_response({'status': 'error', 'message': 'El motivo es obligatorio'}, 400) usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '' monedero = self._monedero_de(partner, crear=True) antes = monedero.points aplicado = monedero.adjust_points( delta, description=motivo, reference=(data.get('reference') or 'AJUSTE MANUAL'), operator=usuario) self._auditar('monedero', f'paciente:{partner.id}', 'ajuste', antes, monedero.points, entity_label=partner.name, notes=motivo) return json_response({ 'status': 'success', 'points': monedero.points, 'applied': aplicado, 'equivalente_mxn': round(monedero.points * float( get_lealtad(request.env).get('valor_punto') or 1.0), 2), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/lealtad', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('lealtad.ver') def report_lealtad(self, **kw): """Puntos otorgados, canjeados, expirados y el pasivo en pesos.""" try: cfg = get_lealtad(request.env) valor = float(cfg.get('valor_punto') or 1.0) 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([]) activos = sum(m.points for m in monederos) otorgados = by_type.get('accrual', 0) + by_type.get('deposito', 0) return json_response({ 'status': 'success', 'modo': cfg.get('modo'), 'valor_punto': valor, 'otorgados': by_type.get('accrual', 0), 'canjeados': abs(by_type.get('redemption', 0)), 'expirados': abs(by_type.get('expiration', 0)), 'ajustes': by_type.get('adjustment', 0), 'depositos': by_type.get('deposito', 0), 'by_type': by_type, 'puntos_activos': activos, 'pasivo_mxn': round(activos * valor, 2), 'cuentas': len(monederos), 'niveles': cfg.get('niveles') or [], 'conteo_niveles': self._conteo_niveles(cfg), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _conteo_niveles(self, cfg): """Cuántos pacientes hay en cada nivel (para el reporte de lealtad).""" conteo = {} for codigo in [n['codigo'] for n in cfg.get('niveles') or []]: conteo[codigo] = 0 partners = request.env['res.partner'].sudo().search([ ('is_patient', '=', True), ('lealtad_nivel_codigo', '!=', False)]) for p in partners: if p.lealtad_nivel_codigo in conteo: conteo[p.lealtad_nivel_codigo] += 1 return conteo @http.route('/skeen/frontend/v1/reports/propinas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('propinas.ver') def report_propinas(self, **kw): """Propinas por empleado y periodo (con desglose de reparto).""" try: cfg = get_propinas(request.env) start = kw.get('start') or (date.today().replace(day=1)).strftime('%Y-%m-%d') end = kw.get('end') or date.today().strftime('%Y-%m-%d') Reparto = request.env['skeen.propina.reparto'].sudo() domain = [('date', '>=', start), ('date', '<=', end)] grupos = Reparto.read_group(domain, ['employee_id', 'monto:sum'], ['employee_id']) rows = [] for g in grupos: emp = g['employee_id'] rows.append({ 'employee_id': emp[0] if emp else None, 'empleado': emp[1] if emp else 'Sin asignar', 'monto': g['monto'], 'propinas': g.get('employee_id_count') or g.get('__count') or 0, }) rows.sort(key=lambda r: -r['monto']) ventas = request.env['skeen.venta'].sudo().search([ ('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled'), ('propina', '>', 0)]) total_ventas = sum(v.propina for v in ventas) detalle = [{ 'venta_id': r.venta_id.id, 'folio': r.venta_id.name, 'date': r.date.strftime('%Y-%m-%d') if r.date else None, 'empleado': r.employee_id.name if r.employee_id else '', 'employee_id': r.employee_id.id if r.employee_id else None, 'rol': r.rol, 'monto': r.monto, 'moneda': r.moneda or 'MXN', 'cuenta': r.cuenta or '', } for r in Reparto.search(domain, order='date desc, id desc', limit=500)] return json_response({ 'status': 'success', 'start': start, 'end': end, 'activo': bool(cfg.get('activo')), 'reparto': cfg.get('reparto'), 'por_empleado': rows, 'detalle': detalle, 'total': total_ventas, 'total_repartido': sum(r['monto'] for r in rows), 'total_ventas': len(ventas), 'cuenta_propinas': cfg.get('cuenta_propinas') or '', }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients//deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('ventas.cobrar') @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//pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('ventas.cobrar') @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//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//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//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//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/', 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/', 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//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 )""" 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/', 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/', 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/', 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/', 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/', 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/', 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('gerencia') @require_perm('servicios.editar') 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), } if 'tax_use_default' in data: vals['tax_use_default'] = bool(data.get('tax_use_default')) if 'tax_rate' in data and data.get('tax_rate') is not None: vals['tax_rate'] = float(data.get('tax_rate') or 0) 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('gerencia') @require_perm('servicios.editar') 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/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('gerencia') @require_perm('servicios.editar') 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', 'tax_rate', 'tax_use_default'] vals = {k: data[k] for k in allowed if k in data} Servicio = request.env['skeen.servicio'].sudo() if 'tax_rate' in vals: vals['tax_rate'] = float(vals['tax_rate'] or 0) if 'tax_use_default' in vals: vals['tax_use_default'] = bool(vals['tax_use_default']) 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, 'tax_rate': s.tax_rate, 'tax_use_default': s.tax_use_default, } # ============================================================ # 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/', 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//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//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//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//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/', 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//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//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') @require_perm('ventas.cobrar') @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//confirm', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') @require_perm('ventas.cobrar') @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/', 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/', 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//pay', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') @require_perm('ventas.cobrar') @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//refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('ventas.devolver') @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//cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('ventas.cancelar') @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, 'tax_total': v.tax_total, 'subtotal_sin_impuesto': v.subtotal_sin_impuesto, '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 '', 'propina': v.propina or 0.0, 'propina_moneda': v.propina_moneda or 'MXN', 'nivel_discount': v.nivel_discount or 0.0, 'nivel_codigo': v.nivel_codigo or '', 'cita_id': v.cita_id.id if v.cita_id else None, 'lines': [{ 'id': line.id, 'service_id': line.service_id.id if line.service_id else None, 'service': line.service_id.name if line.service_id else '', 'item_id': line.item_id.id if line.item_id else None, 'item': line.item_id.name if line.item_id else '', 'description': line.description or '', 'quantity': line.quantity, 'price_unit': line.price_unit, 'subtotal': line.subtotal, 'tax_rate': line.tax_rate, 'tax_amount': line.tax_amount, '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') @require_perm('ventas.vender') @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) if discount > 0 and not has_perm(request.frontend_user, 'ventas.descuentos'): return json_response({ 'status': 'error', 'message': 'Sin permiso: %s' % PERM_LABELS.get('ventas.descuentos', 'Aplicar descuentos'), }, 403) 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 = [] # Impuestos (config de la instancia). Apagados => todo idéntico a hoy. cfg_imp = get_impuestos(request.env) imp_activo = bool(cfg_imp.get('activo')) imp_incluye = bool(cfg_imp.get('precios_incluyen_impuesto')) puede_tax = has_perm(request.frontend_user, 'ventas.impuestos') sale_tax = data.get('tax_rate') # tasa por venta (opcional) base_total = 0.0 tax_total = 0.0 bruto_total = 0.0 def _tasa_linea(servicio=None, item=None, pedida=None): """Tasa (%) efectiva de una línea: catálogo -> default -> pedida.""" if servicio is not None: cat = float(cfg_imp.get('tasa_default') or 0.0) if servicio.tax_use_default \ else float(servicio.tax_rate or 0.0) elif item is not None: cat = float(cfg_imp.get('tasa_default') or 0.0) if item.tax_use_default \ else float(item.tax_rate or 0.0) else: cat = float(cfg_imp.get('tasa_default') or 0.0) if not imp_activo: return 0.0 if pedida is None: return cat pedida = float(pedida) # Sin permiso de impuestos se ignora el ajuste y manda el # catálogo (la UI ya lo deshabilita; el backend es la autoridad). if abs(pedida - cat) > 0.000001 and not puede_tax: return cat return pedida 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) pedida = l.get('tax_rate', sale_tax) bruto = 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) tasa = _tasa_linea(item=item, pedida=pedida) line_vals.append((0, 0, { 'item_id': item.id, 'description': l.get('description') or item.name, 'quantity': qty, 'price_unit': price, 'tax_rate': tasa, '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) tasa = _tasa_linea(servicio=servicio, pedida=pedida) line_vals.append((0, 0, { 'service_id': servicio.id, 'description': l.get('description') or servicio.name, 'quantity': qty, 'price_unit': price, 'tax_rate': tasa, '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)) # Desglose por línea (mismo cálculo que el modelo). bruto_total += bruto if tasa <= 0: base_total += bruto elif imp_incluye: base = bruto / (1.0 + tasa / 100.0) base_total += base tax_total += bruto - base else: base_total += bruto tax_total += bruto * tasa / 100.0 total_est = max(0.0, base_total - discount + tax_total) # Lealtad y propinas de la instancia. Con propinas apagadas y # lealtad clásica, `propina=0` y `nivel_discount=0`: el POS sale # idéntico al de siempre. cfg_lea = get_lealtad(request.env) cfg_prop = get_propinas(request.env) cita_id = int(data.get('cita_id', 0) or 0) propina_pct = data.get('propina_pct') propina_monto = data.get('propina') propina_employee_id = int(data.get('propina_employee_id', 0) or 0) cobrador_employee_id = int(data.get('cobrador_employee_id', 0) or 0) # 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', 'saldo_favor'): 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, 'cita_id': cita_id or False, }) venta.action_confirm() # Descuento automático del nivel VIP (0 con lealtad clásica). nivel_pct = venta._aplicar_descuento_nivel(partner) or 0.0 # Propina: solo con el módulo encendido. Se calcula sobre los # servicios (sin impuesto no; base = subtotal - descuentos + impuesto) # y se guarda APARTE del importe de los servicios. propina = 0.0 propina_moneda = (currency if currency in ('MXN', 'USD') else 'MXN') if cfg_prop.get('activo'): servicios_total = max( 0.0, venta.subtotal - venta.discount - venta.nivel_discount + venta.tax) if propina_pct not in (None, ''): try: pct = float(propina_pct) except (TypeError, ValueError): pct = 0.0 if pct < 0: pct = 0.0 if not cfg_prop.get('permitir_personalizado') and pct <= 0: pct = 0.0 propina = round(servicios_total * pct / 100.0, 2) elif propina_monto not in (None, ''): try: propina = round(float(propina_monto or 0), 2) except (TypeError, ValueError): propina = 0.0 elif not cfg_prop.get('permitir_sin_propina'): return json_response({'status': 'error', 'message': 'Esta clínica exige registrar propina'}, 400) if propina < 0: propina = 0.0 if propina > 0: venta.write({ 'propina': propina, 'propina_moneda': propina_moneda, 'propina_employee_id': propina_employee_id or False, }) # 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). # Clásico: cubre el 100% del servicio (comportamiento de siempre). # Configurable: se respeta `canje_max_pct`. La propina nunca se # cubre con puntos: siempre se paga en dinero. puntos_usados = 0 if pay_with_points: import math valor_punto = float(cfg_lea.get('valor_punto') or 1.0) or 1.0 base_canje = venta.total if cfg_lea.get('modo') == 'configurable': tope = venta.total * float(cfg_lea.get('canje_max_pct') or 0.0) / 100.0 base_canje = min(base_canje, tope) base_canje = min(base_canje, max(0.0, venta.total - venta.propina)) puntos = int(math.ceil(base_canje / valor_punto - 1e-9)) 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': round(puntos * valor_punto, 2)}) 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_vals = { 'partner_id': partner.id, 'venta_id': venta.id, 'amount': pay_amount, 'payment_method': method, 'state': 'completed', 'payment_date': fields.Datetime.now(), 'provider_reference': reference, } if currency == 'USD' and rate_used: # Cobro en dólares: registrar el pago en su moneda real # (USD). Los totales de la venta (amount_paid) siguen en # MXN; el concentrado pivotea por moneda del pago. pago_vals['amount'] = round(pay_amount / rate_used, 2) pago_vals['currency_id'] = request.env.ref('base.USD').id pago = request.env['skeen.pago'].sudo().create(pago_vals) # '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() # Reparto de la propina entre el personal (solo con propinas # activas y regla distinta de 'ninguno'). if cfg_prop.get('activo') and (venta.propina or 0) > 0: Emp = request.env['hr.employee'].sudo() cobrador_emp = Emp.browse() for cand in (cobrador_employee_id, propina_employee_id): if cand: rec = Emp.browse(cand) if rec.exists(): cobrador_emp = rec break venta._generar_reparto_propina( cfg_prop, cobrador_employee=cobrador_emp or None, cobrador_login=cobrador) partner.invalidate_recordset() # Desglose de impuestos cobrados (con impuestos apagados: base = # subtotal, impuesto = 0, total = el de siempre). desglose = { 'activo': imp_activo, 'nombre': cfg_imp.get('nombre') or 'Impuesto', 'incluido': imp_incluye, 'subtotal': venta.subtotal_sin_impuesto, 'descuento': venta.discount, 'impuesto': venta.tax_total, 'total': venta.total, } if rate_used: desglose['subtotal_usd'] = round(desglose['subtotal'] / rate_used, 2) desglose['impuesto_usd'] = round(desglose['impuesto'] / rate_used, 2) desglose['total_usd'] = round(desglose['total'] / rate_used, 2) return json_response({ 'status': 'success', 'sale': self._sale_to_dict(venta), 'payment': self._payment_to_dict(pago) if pago else None, 'subtotal': desglose['subtotal'], 'descuento': desglose['descuento'], 'impuesto': desglose['impuesto'], 'total': desglose['total'], 'impuestos': desglose, 'cambio': cambio, 'recibido': recibido, 'cambio_usd': cambio_usd, 'rate_used': rate_used, 'recibido_usd': recibido_usd, 'puntos_usados': puntos_usados, 'puntos_ganados': venta._wallet_points_expected() if venta.state == 'paid' else 0, 'wallet_points': partner.wallet_points, 'propina': venta.propina or 0.0, 'propina_moneda': venta.propina_moneda or 'MXN', 'nivel_discount': venta.nivel_discount or 0.0, 'nivel': ({ 'codigo': partner.lealtad_nivel_codigo or '', 'nombre': partner.lealtad_nivel_nombre or '', 'descuento_pct': partner.lealtad_descuento_pct or 0.0, } if cfg_lea.get('modo') == 'configurable' and partner.lealtad_nivel_codigo else None), 'reparto_propina': [{ 'employee_id': r.employee_id.id if r.employee_id else None, 'empleado': r.employee_id.name if r.employee_id else '', 'rol': r.rol, 'monto': r.monto, 'moneda': r.moneda or 'MXN', } for r in venta.reparto_ids], '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 ValidationError as e: msg = e.args[0] if e.args else str(e) status = 403 if str(msg).startswith('Sin permiso') else 400 return json_response({'status': 'error', 'message': msg}, status) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Cobros y enlaces de pago # ============================================================ def _cobro_to_dict(self, e): return { 'id': e.id, 'nombre': e.name, 'origen': e.origen, 'origen_label': dict(e._fields['origen'].selection).get(e.origen, e.origen), 'estado': e.estado, 'estado_label': dict(e._fields['estado'].selection).get(e.estado, e.estado), 'proveedor': e.proveedor, 'partner_id': e.partner_id.id if e.partner_id else None, 'patient': e.partner_id.name if e.partner_id else '', 'cita_id': e.cita_id.id if e.cita_id else None, 'folio_cita': e.cita_id.name if e.cita_id else '', 'venta_id': e.venta_id.id if e.venta_id else None, 'concepto': e.concepto or '', 'monto': e.monto, 'moneda': e.currency_id.name or 'MXN', 'creado': e.create_date.strftime('%Y-%m-%d %H:%M') if e.create_date else None, 'expira': e.token_expira.strftime('%Y-%m-%d %H:%M') if e.token_expira else None, 'fecha_pago': e.fecha_pago.strftime('%Y-%m-%d %H:%M') if e.fecha_pago else None, 'recordatorios': e.recordatorios, 'url': e.url or '', } @http.route('/skeen/frontend/v1/cobros', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('cobros.ver') def list_cobros(self, **kw): """Listado de enlaces de cobro (pendientes, pagados, expirados).""" try: domain = [] if kw.get('estado'): domain.append(('estado', '=', kw.get('estado'))) if kw.get('origen'): domain.append(('origen', '=', kw.get('origen'))) enlaces = request.env['skeen.cobro.enlace'].sudo().search( domain, order='create_date desc', limit=200) return json_response({ 'status': 'success', 'cobros': [self._cobro_to_dict(e) for e in enlaces], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/cobros//forzar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('cobros.forzar') @forbid_roles('marketing') def forzar_cobro(self, enlace_id, **kw): """Registra el pago de un enlace sin pasar por la pasarela.""" try: enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id) if not enlace.exists(): return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404) if enlace.estado == 'pagado': return json_response({'status': 'error', 'message': 'El enlace ya está pagado'}, 409) user = current_user(request.env) data = _parse_json_body() pago = request.env['skeen.cobro.service'].sudo().confirmar( enlace, proveedor=(data.get('proveedor') or enlace.proveedor or 'simulado'), referencia=(data.get('referencia') or 'FORZADO'), response='Forzado desde el panel', user=(user.login if user else 'staff'), force=True) if not pago: return json_response({'status': 'error', 'message': 'No se pudo registrar el pago'}, 409) self._auditar('cobros', 'cobro', str(enlace.id), new_value='forzado', entity_label=enlace.concepto or enlace.name, notes='Pago forzado') return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace), 'pago': pago.name}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/cobros//cancelar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('cobros.cancelar') def cancelar_cobro(self, enlace_id, **kw): """Cancela un enlace pendiente y libera la retención (cita/periodo).""" try: enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id) if not enlace.exists(): return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404) if enlace.estado != 'pendiente': return json_response({'status': 'error', 'message': 'Solo se pueden cancelar enlaces pendientes'}, 409) data = _parse_json_body() user = current_user(request.env) enlace.action_cancelar(motivo=(data.get('motivo') or 'Cancelado desde el panel'), user=(user.login if user else 'staff')) return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/cobros', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('reportes.ver') def report_cobros(self, **kw): """Reporte de cobros pendientes y expirados (anticipos y membresías).""" try: start = kw.get('start') end = kw.get('end') domain = [('estado', 'in', ('pendiente', 'expirado', 'cancelado'))] if start: domain.append(('create_date', '>=', f'{start} 00:00:00')) if end: domain.append(('create_date', '<=', f'{end} 23:59:59')) enlaces = request.env['skeen.cobro.enlace'].sudo().search(domain, order='estado, token_expira') ahora = fields.Datetime.now() filas = [] resumen = {'pendiente': 0.0, 'expirado': 0.0, 'cancelado': 0.0} for e in enlaces: vencido = bool(e.estado == 'pendiente' and e.token_expira and e.token_expira < ahora) filas.append({ **self._cobro_to_dict(e), 'vencido': vencido, 'dias_para_vencer': ((e.token_expira - ahora).days if e.estado == 'pendiente' and e.token_expira else 0), }) if e.estado in resumen: resumen[e.estado] += e.monto return json_response({ 'status': 'success', 'start': start, 'end': end, 'cobros': filas, 'total': len(filas), 'resumen': resumen, }) 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//close', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') @require_perm('caja.cerrar') @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), 'total_propinas': sum(v.propina or 0.0 for v in ventas), 'total_servicios': sum(v.total - (v.propina or 0.0) 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), 'total_propinas': sum(v.propina or 0.0 for v in ventas), 'total_servicios': sum(v.total - (v.propina or 0.0) 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 not b.doctor_id or 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), 'total_propinas': sum(v.propina or 0.0 for v in ventas), 'total_servicios': sum(v.total - (v.propina or 0.0) 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/concentrado/detalle', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_concentrado_detalle(self, **kw): """Concentrado estilo legacy: una fila por venta con desglose de pagos por método (efectivo, tarjeta, transferencia, saldo a favor, monedero) en cada moneda (USD / M.N.), más totales, pacientes atendidos y ticket promedio en MXN. Filtros opcionales: doctor_ids (líneas prescribed_by_id, o el médico de la cita origen si la venta no trae recetado), categoria_ids (category de skeen.servicio), servicio_ids y vip (etiqueta VIP del paciente). """ try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) def _parse_ints(raw): out = [] for chunk in str(raw or '').split(','): chunk = chunk.strip() if chunk: try: out.append(int(chunk)) except ValueError: pass return out doctor_ids = _parse_ints(kw.get('doctor_ids')) categoria_ids = [c.strip() for c in str(kw.get('categoria_ids') or '').split(',') if c.strip()] servicio_ids = _parse_ints(kw.get('servicio_ids')) solo_vip = str(kw.get('vip') or '').lower() in ('1', 'true', 'yes') ventas = request.env['skeen.venta'].sudo().search([ ('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end), ], order='date asc, id asc') if doctor_ids: ventas = ventas.filtered(lambda v: ( any(l.prescribed_by_id.id in doctor_ids for l in v.line_ids) or (not any(l.prescribed_by_id for l in v.line_ids) and v.cita_id and v.cita_id.doctor_id and v.cita_id.doctor_id.id in doctor_ids) )) if servicio_ids: ventas = ventas.filtered(lambda v: any( l.service_id and l.service_id.id in servicio_ids for l in v.line_ids)) if categoria_ids: ventas = ventas.filtered(lambda v: any( l.service_id and l.service_id.category in categoria_ids for l in v.line_ids)) if solo_vip: ventas = ventas.filtered(lambda v: v.partner_id.is_vip) # Pagos cobrados de las ventas (una venta puede tener varios: pago # mixto). Se pivotea por método de pago y por moneda del pago. pagos_by_venta = {} if ventas: pagos = request.env['skeen.pago'].sudo().search([ ('venta_id', 'in', ventas.ids), ('state', '=', 'completed'), ]) for p in pagos: pagos_by_venta.setdefault(p.venta_id.id, []).append(p) METODOS = ('efectivo', 'tarjeta', 'transferencia', 'saldo_favor', 'monedero') METODO_COL = { 'cash': 'efectivo', 'card': 'tarjeta', 'stripe': 'tarjeta', 'mercadopago': 'tarjeta', 'transfer': 'transferencia', 'saldo_favor': 'saldo_favor', 'points': 'monedero', } COLUMNAS = [f'{m}_{cur}' for m in METODOS for cur in ('usd', 'mxn')] def _desglose(venta): vals = {c: 0.0 for c in COLUMNAS} for p in pagos_by_venta.get(venta.id, []): col = METODO_COL.get(p.payment_method) if not col: continue sufijo = 'usd' if p.currency_id and p.currency_id.name == 'USD' else 'mxn' vals[f'{col}_{sufijo}'] += p.amount return {c: round(v, 2) for c, v in vals.items()} # Vendedor: user_login (los controladores auth='none' no dejan uid # en create_uid) -> nombre completo del usuario del frontend. logins = list({v.user_login for v in ventas if v.user_login}) nombres_vendedor = {} if logins: for u in request.env['skeen.frontend.user'].sudo().search([('login', 'in', logins)]): nombres_vendedor[u.login] = u.name # Tipo de cambio del día de cada venta (o el más reciente # disponible) para expresar el ticket promedio en MXN. TipoCambio = request.env['skeen.tipo.cambio'].sudo() tc_por_fecha = {} def _tc(fecha): key = str(fecha) if key not in tc_por_fecha: tc = TipoCambio.search([('date', '<=', key)], order='date desc, id desc', limit=1) if not tc: tc = TipoCambio.search([], order='date desc, id desc', limit=1) tc_por_fecha[key] = tc.rate if tc else 0.0 return tc_por_fecha[key] rows = [] equiv_mxn = [] for v in ventas: desglose = _desglose(v) medico = next((l.prescribed_by_id.name for l in v.line_ids if l.prescribed_by_id), None) if not medico and v.cita_id and v.cita_id.doctor_id: medico = v.cita_id.doctor_id.name concepto = ', '.join( (l.service_id.name or l.description or '') if l.service_id else (l.item_id.name or l.description or '') for l in v.line_ids) fecha = v.date.strftime('%Y-%m-%d') if v.date else '' if fecha and v.create_date: fecha = f"{fecha} {v.create_date.strftime('%H:%M')}" rows.append({ 'folio': v.name or '', 'fecha': fecha, 'paciente': v.partner_id.name or '', 'folio_dr': v.partner_id.patient_id or None, 'medico': medico or '', 'vendedor': nombres_vendedor.get(v.user_login, v.user_login or '') if v.user_login else '', 'concepto': concepto, **desglose, }) mxn = sum(desglose[f'{m}_mxn'] for m in METODOS) usd = sum(desglose[f'{m}_usd'] for m in METODOS) equiv_mxn.append(mxn + usd * _tc(v.date)) totals = {c: round(sum(r[c] for r in rows), 2) for c in COLUMNAS} pacientes_atendidos = len({v.partner_id.id for v in ventas}) ticket_promedio = round(sum(equiv_mxn) / len(equiv_mxn), 2) if equiv_mxn else 0.0 return json_response({ 'status': 'success', 'start': start, 'end': end, 'rows': rows, 'totals': totals, 'pacientes_atendidos': pacientes_atendidos, 'ticket_promedio': ticket_promedio, }) 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([]) activos = sum(m.points for m in monederos) # El valor del punto sale de la lealtad; en modo clásico es 1.0, así # que `equivalente_mxn` conserva la cifra de siempre. valor = float(get_lealtad(request.env).get('valor_punto') or 1.0) return json_response({ 'status': 'success', 'by_type': by_type, 'puntos_activos': activos, 'cuentas': len(monederos), 'equivalente_mxn': activos * valor, 'valor_punto': valor, 'expirados': abs(by_type.get('expiration', 0)), 'pasivo_mxn': round(activos * valor, 2), }) 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/taxes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('reportes.ver') def report_taxes(self, **kw): """Impuestos del periodo, agrupables por mes, servicio y sucursal. Con los impuestos apagados las tasas de las líneas son 0, así que la respuesta sale en ceros (no altera ninguna cifra existente). Solo cuenta ventas no canceladas, igual que el resto de reportes de dinero. """ try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) cfg = get_impuestos(request.env) ventas = request.env['skeen.venta'].sudo().search([ ('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end), ]) por_mes = {} por_servicio = {} por_sucursal = {} total_base = 0.0 total_impuesto = 0.0 for v in ventas: mes = v.date.strftime('%Y-%m') if v.date else '' sucursal = v.cita_id.branch if v.cita_id and v.cita_id.branch else (v.branch if hasattr(v, 'branch') else '') or 'sin_sucursal' for line in v.line_ids: base = line.subtotal or 0.0 impuesto = line.tax_amount or 0.0 total_base += base total_impuesto += impuesto m = por_mes.setdefault(mes, {'base': 0.0, 'impuesto': 0.0, 'ventas': 0}) m['base'] += base m['impuesto'] += impuesto nombre = (line.service_id.name if line.service_id else line.item_id.name if line.item_id else line.description or 'Sin concepto') s = por_servicio.setdefault(nombre, {'base': 0.0, 'impuesto': 0.0, 'cantidad': 0.0}) s['base'] += base s['impuesto'] += impuesto s['cantidad'] += line.quantity or 0.0 su = por_sucursal.setdefault(sucursal, {'base': 0.0, 'impuesto': 0.0}) su['base'] += base su['impuesto'] += impuesto # 'ventas' por mes: cuántos tickets del mes tocaron impuestos por_mes[mes]['ventas'] += 1 return json_response({ 'status': 'success', 'start': start, 'end': end, 'activo': bool(cfg.get('activo')), 'nombre': cfg.get('nombre') or 'Impuesto', 'incluido': bool(cfg.get('precios_incluyen_impuesto')), 'total_base': total_base, 'total_impuesto': total_impuesto, 'total': total_base + total_impuesto, 'por_mes': [ {'mes': k, **v} for k, v in sorted(por_mes.items()) ], 'por_servicio': [ {'servicio': k, **v} for k, v in sorted(por_servicio.items(), key=lambda kv: -kv[1]['impuesto']) ], 'por_sucursal': [ {'sucursal': k, **v} for k, v in por_sucursal.items() ], }) 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') @require_perm('config.editar') 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') @require_perm('config.editar') 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') @require_perm('config.editar') 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') @require_perm('config.editar') 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') @require_perm('config.auditoria') 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) url_publica - URL pública de la instancia (opcional; la usan los enlaces de pago y correos) colores - {'primario': '#hex', 'acento': '#hex'} (opcional) """ defaults = { 'nombre': 'Clínica', 'logo_claro': '/brand/logo-blanco.svg', 'logo_oscuro': '/brand/logo-negro.svg', 'pie': 'Sistema de gestión clínica', 'url_publica': '', 'colores': {'primario': '', 'acento': ''}, } raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY) if raw: try: guardado = json.loads(raw) if isinstance(guardado, dict): for k in ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica'): v = guardado.get(k) if v is not None and str(v).strip(): defaults[k] = str(v) colores = guardado.get('colores') if isinstance(colores, dict): for c in ('primario', 'acento'): v = colores.get(c) if v is not None and str(v).strip(): defaults['colores'][c] = str(v) 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') @require_perm('config.editar') 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', 'url_publica') if brand.get(k) is not None and str(brand.get(k)).strip()} # Colores de marca (opcional). Formato hex básico; se ignora lo vacío. colores = brand.get('colores') if colores is not None: if not isinstance(colores, dict): return json_response({'status': 'error', 'message': 'colores debe ser un objeto'}, 400) limpio_colores = {} for clave in ('primario', 'acento'): valor = colores.get(clave) if valor is None: continue valor = str(valor).strip() if not valor: continue if not re.match(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', valor): return json_response({ 'status': 'error', 'message': 'El color %s debe ser hex (#RRGGBB)' % clave}, 400) limpio_colores[clave] = valor if limpio_colores: limpio['colores'] = limpio_colores 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) # ============================================================ # Terminología de la clínica (glosario) — white-label # ============================================================ def _puede_configurar_catalogo(self): """Sucursales, categorías y terminología: admin o gerencia.""" u = self._fu_user() return bool(u and u.role in ('admin', 'gerencia')) @http.route('/skeen/frontend/v1/glosario', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_glosario(self, **kw): """Glosario vigente: siempre las 24 claves resueltas (pack + overrides).""" try: pack, terms = resolver_glosario(request.env) return json_response({'status': 'success', 'pack': pack, 'terms': terms}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/glosario/packs', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_glosario_packs(self, **kw): """Packs predefinidos con nombre legible, para el selector.""" try: return json_response({'status': 'success', 'packs': list_glosario_packs()}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/glosario', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def put_glosario(self, **kw): """Guarda pack y overrides. Los overrides pisan al pack. Body: {"pack": "spa", "overrides": {"patient_singular": "Huésped"}} Si se omite una de las dos claves, se conserva lo que ya había. Los overrides enviados reemplazan a los anteriores (no se mezclan). """ try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() pack = data.get('pack') overrides = data.get('overrides') if pack is not None and str(pack).strip(): if str(pack).strip() not in [p['code'] for p in list_glosario_packs()]: return json_response({ 'status': 'error', 'message': 'Pack no válido: %s' % pack}, 400) if overrides is not None and not isinstance(overrides, dict): return json_response({'status': 'error', 'message': 'overrides debe ser un objeto'}, 400) if isinstance(overrides, dict): desconocidas = [k for k in overrides if k not in GLOSARIO_KEY_SET] if desconocidas: return json_response({ 'status': 'error', 'message': 'Claves desconocidas: %s' % ', '.join(desconocidas)}, 400) antes = get_glosario_config(request.env) set_glosario_config(request.env, pack=pack, overrides=overrides) pack_actual, terms = resolver_glosario(request.env) self._auditar('clinica', 'glosario', 'terminologia', old_value=json.dumps(antes, ensure_ascii=False)[:500], new_value=json.dumps({'pack': pack_actual, 'overrides': get_glosario_config(request.env)['overrides']}, ensure_ascii=False)[:500], entity_label='Terminología de la clínica (glosario)', notes=(data.get('motivo') or 'actualizado').strip()) return json_response({'status': 'success', 'pack': pack_actual, 'terms': terms, 'message': 'Terminología actualizada'}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Ajustes de la clínica por instancia # ============================================================ @http.route('/skeen/frontend/v1/clinic-settings', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_clinic_settings_api(self, **kw): """Ajustes de la clínica (nombre, contacto, horario, moneda...).""" try: return json_response({'status': 'success', 'settings': get_clinic_settings(request.env)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/clinic-settings', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def put_clinic_settings_api(self, **kw): """Guarda los ajustes mergeando sobre lo actual. Acepta los campos al nivel raíz o dentro de {"settings": {...}}. """ try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() vals = data.get('settings') if isinstance(data.get('settings'), dict) else data antes = get_clinic_settings(request.env) nuevo = set_clinic_settings(request.env, vals) self._auditar('clinica', 'clinic-settings', 'ajustes', old_value=json.dumps(antes, ensure_ascii=False)[:500], new_value=json.dumps(nuevo, ensure_ascii=False)[:500], entity_label='Ajustes de la clínica', notes=(data.get('motivo') or 'actualizado').strip()) return json_response({'status': 'success', 'settings': nuevo, 'message': 'Ajustes actualizados'}) except ValueError 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) # ============================================================ # Recursos de la agenda (configurables: cabinas, salas, equipos) # ============================================================ def _recursos_payload(self, data): """IDs de recurso de un body (lista `recurso_ids` o CSV `recurso_ids`). Descarta valores no numéricos o inexistentes para no romper el guardado. """ raw = data.get('recurso_ids') if raw is None: raw = data.get('recurso_ids_csv') ids = [] if isinstance(raw, (list, tuple)): for v in raw: try: ids.append(int(v)) except (TypeError, ValueError): continue elif raw not in (None, ''): for v in str(raw).split(','): v = v.strip() if v.isdigit(): ids.append(int(v)) ids = list(dict.fromkeys(ids)) if not ids: return [] existentes = request.env['skeen.recurso'].sudo().search([('id', 'in', ids)]).ids return [i for i in ids if i in existentes] def _recurso_vals_cita(self, data): """`{recurso_ids: [(6,0,ids)]}` si el módulo está activo y hay recursos.""" cfg = get_recursos(request.env) if not cfg['activo']: return {} ids = self._recursos_payload(data) if not cfg['permitir_multiples']: ids = ids[:1] return {'recurso_ids': [(6, 0, ids)]} if ids else {} def _recurso_to_dict(self, r): return { 'id': r.id, 'code': r.code, 'name': r.name, 'tipo': r.tipo, 'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''), 'activo': r.activo, 'sequence': r.sequence, 'sucursales': [{'id': s.id, 'code': s.code, 'name': s.name} for s in r.sucursales], 'capacidad': r.capacidad or 0, 'costo_hora': r.costo_hora or 0.0, 'color': r.color or '', 'notas': r.notas or '', } @http.route('/skeen/frontend/v1/recursos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('recursos.ver') def list_recursos(self, **kw): """Recursos. Por defecto solo activos; ?include_inactive=1 trae todos.""" try: incluir = kw.get('include_inactive') in ('1', 'true', 'True') domain = [] if incluir else [('activo', '=', True)] recs = request.env['skeen.recurso'].sudo().search(domain, order='sequence, name') return json_response({'status': 'success', 'recursos': [self._recurso_to_dict(r) for r in recs], 'total': len(recs)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/recursos', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') @require_perm('recursos.editar') def create_recurso(self, **kw): """Crea un recurso. Sin `code` se genera un slug del nombre.""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) Recurso = request.env['skeen.recurso'].sudo() codigo = (data.get('code') or '').strip() if codigo: codigo = catalog_slugify(codigo) or codigo if Recurso.search_count([('code', '=', codigo)]): return json_response({ 'status': 'error', 'message': 'Ya existe un recurso con el código %s' % codigo}, 409) else: codigo = catalog_unique_slug(Recurso, name) vals = self._recurso_vals(data) vals.update({'name': name, 'code': codigo}) recurso = Recurso.create(vals) self._auditar('catalogos', 'recurso:%s' % recurso.id, 'crear', new_value=json.dumps(self._recurso_to_dict(recurso), ensure_ascii=False)[:500], entity_label='Recurso %s' % recurso.name, notes=(data.get('motivo') or 'creado').strip()) return json_response({'status': 'success', 'recurso': self._recurso_to_dict(recurso)}, 201) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) def _recurso_vals(self, data): """Campos comunes de alta/edición de un recurso (sin name/code).""" vals = {} if 'tipo' in data: tipos = dict(request.env['skeen.recurso']._fields['tipo'].selection) vals['tipo'] = data['tipo'] if data.get('tipo') in tipos else 'otro' for campo in ('notas', 'color'): if campo in data: vals[campo] = str(data.get(campo) or '').strip() if 'sequence' in data: try: vals['sequence'] = int(data['sequence']) except (TypeError, ValueError): raise ValueError('La secuencia debe ser numérica') if 'capacidad' in data: try: vals['capacidad'] = max(0, int(data['capacidad'] or 0)) except (TypeError, ValueError): raise ValueError('La capacidad debe ser un número entero') if 'costo_hora' in data: try: vals['costo_hora'] = max(0.0, float(data['costo_hora'] or 0)) except (TypeError, ValueError): raise ValueError('El costo por hora debe ser un número') if 'activo' in data: vals['activo'] = bool(data['activo']) if 'sucursal_ids' in data: ids = data.get('sucursal_ids') or [] if not isinstance(ids, list): raise ValueError('sucursal_ids debe ser una lista') vals['sucursales'] = [(6, 0, [int(i) for i in ids if str(i).isdigit()])] return vals @http.route('/skeen/frontend/v1/recursos/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('recursos.editar') def update_recurso(self, recurso_id, **kw): try: if request.httprequest.method == 'OPTIONS': return json_response({}, 200) if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) recurso = request.env['skeen.recurso'].sudo().browse(recurso_id) if not recurso.exists(): return json_response({'status': 'error', 'message': 'Recurso no encontrado'}, 404) data = _parse_json_body() vals = {} if 'name' in data and str(data.get('name') or '').strip(): vals['name'] = str(data['name']).strip() if 'code' in data: codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip() if codigo and codigo != recurso.code: dup = request.env['skeen.recurso'].sudo().search_count( [('code', '=', codigo), ('id', '!=', recurso.id)]) if dup: return json_response({ 'status': 'error', 'message': 'Ya existe un recurso con el código %s' % codigo}, 409) vals['code'] = codigo try: vals.update(self._recurso_vals(data)) except ValueError as e: return json_response({'status': 'error', 'message': str(e)}, 400) if not vals: return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) recurso.write(vals) self._auditar('catalogos', 'recurso:%s' % recurso.id, 'editar', new_value=json.dumps(self._recurso_to_dict(recurso), ensure_ascii=False)[:500], entity_label='Recurso %s' % recurso.name, notes=(data.get('motivo') or 'actualizado').strip()) return json_response({'status': 'success', 'recurso': self._recurso_to_dict(recurso)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/recursos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') @require_perm('recursos.editar') def delete_recurso(self, recurso_id, **kw): """Desactiva el recurso (nunca se borra: las citas lo referencian).""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) recurso = request.env['skeen.recurso'].sudo().browse(recurso_id) if not recurso.exists(): return json_response({'status': 'error', 'message': 'Recurso no encontrado'}, 404) en_uso = request.env['skeen.cita'].sudo().search_count( [('recurso_ids', 'in', recurso.id)]) recurso.write({'activo': False}) self._auditar('catalogos', 'recurso:%s' % recurso.id, 'desactivar', old_value=json.dumps(self._recurso_to_dict(recurso), ensure_ascii=False)[:500], entity_label='Recurso %s' % recurso.name, notes='desactivado (%d citas)' % en_uso) mensaje = 'Recurso desactivado.' if en_uso: mensaje = ('El recurso tiene %d cita(s) registradas y no se puede ' 'eliminar; quedó desactivado.' % en_uso) return json_response({'status': 'success', 'recurso': self._recurso_to_dict(recurso), 'en_uso': en_uso, 'message': mensaje}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/recursos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_recursos(self, **kw): """Ocupación por recurso: citas, horas ocupadas, % y costo estimado. Horas disponibles = días del rango × jornada de la clínica. El % de ocupación es horas ocupadas / disponibles. `ingresos_estimados` usa el `costo_hora` del recurso cuando está definido (0 si no). """ try: start = kw.get('start', date.today().strftime('%Y-%m-%d')) end = kw.get('end', start) try: d_ini = datetime.strptime(str(start)[:10], '%Y-%m-%d').date() d_fin = datetime.strptime(str(end)[:10], '%Y-%m-%d').date() except ValueError: return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400) if d_fin < d_ini: d_ini, d_fin = d_fin, d_ini dias = (d_fin - d_ini).days + 1 ini_h, fin_h = self._agenda_bounds() horas_dia = max(0.0, fin_h - ini_h) disponibles = horas_dia * dias citas = request.env['skeen.cita'].sudo().search([ ('date', '>=', d_ini.strftime('%Y-%m-%d')), ('date', '<=', d_fin.strftime('%Y-%m-%d')), ('state', 'not in', INACTIVE_STATES), ]) acumulado = {} for c in citas: for r in c.recurso_ids: acc = acumulado.setdefault(r.id, {'citas': 0, 'minutos': 0}) acc['citas'] += 1 acc['minutos'] += c._duracion() recursos = request.env['skeen.recurso'].sudo().search( [('activo', '=', True)], order='sequence, name') filas = [] for r in recursos: acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0}) horas = round(acc['minutos'] / 60.0, 2) ocupacion = round((horas / disponibles * 100.0), 1) if disponibles else 0.0 filas.append({ 'recurso_id': r.id, 'recurso': r.name, 'code': r.code, 'tipo': r.tipo, 'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''), 'color': r.color or '', 'citas': acc['citas'], 'horas_ocupadas': horas, 'horas_disponibles': round(disponibles, 2), 'ocupacion_pct': min(ocupacion, 100.0), 'costo_hora': r.costo_hora or 0.0, 'ingresos_estimados': round(horas * (r.costo_hora or 0.0), 2), }) total_horas = round(sum(f['horas_ocupadas'] for f in filas), 2) total_citas = sum(f['citas'] for f in filas) total_ingresos = round(sum(f['ingresos_estimados'] for f in filas), 2) return json_response({ 'status': 'success', 'start': d_ini.strftime('%Y-%m-%d'), 'end': d_fin.strftime('%Y-%m-%d'), 'dias': dias, 'horas_disponibles': round(disponibles, 2), 'recursos': filas, 'totales': {'citas': total_citas, 'horas_ocupadas': total_horas, 'ingresos_estimados': total_ingresos}, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Tablero de dirección (indicadores del dueño) # # Todas las cifras se calculan con los MISMOS criterios que los reportes # existentes: ventas con state != 'cancelled' (igual que /reports/sales y # /reports/concentrado), cobros de pagos state='completed' (igual que # /reports/cash), no-show y ocupación con las reglas de /reports/kpis, # recursos con /reports/recursos y franjas con /reports/horas-agenda. # Es un tablero de SOLO LECTURA; requiere el permiso `direccion.ver`. # ============================================================ DIRECCION_MAX_COHORTES = 12 def _dir_minus_year(self, d): """Mismo día del año anterior (29-feb cae al 28 en años no bisiestos).""" try: return d.replace(year=d.year - 1) except ValueError: return d.replace(year=d.year - 1, day=28) def _dir_add_months(self, d, n): y = d.year + (d.month - 1 + n) // 12 m = (d.month - 1 + n) % 12 + 1 return date(y, m, min(d.day, calendar.monthrange(y, m)[1])) def _dir_mes_key(self, d): return d.strftime('%Y-%m') def _dir_domain_sucursal(self, modelo, sucursal): """Filtro por sucursal. Las ventas y los pagos no guardan sucursal propia: se atribuyen a la de su cita origen (`cita_id.branch`).""" if not sucursal: return [] if modelo == 'skeen.cita': return [('branch', '=', sucursal)] if modelo in ('skeen.venta', 'skeen.pago'): return [('cita_id.branch', '=', sucursal)] return [] def _dir_periodo_anterior(self, start, end): d1 = datetime.strptime(start, '%Y-%m-%d').date() d2 = datetime.strptime(end, '%Y-%m-%d').date() dias = (d2 - d1).days + 1 prev_fin = d1 - timedelta(days=1) prev_ini = prev_fin - timedelta(days=dias - 1) return prev_ini.strftime('%Y-%m-%d'), prev_fin.strftime('%Y-%m-%d') def _dir_periodo_anio(self, start, end): d1 = datetime.strptime(start, '%Y-%m-%d').date() d2 = datetime.strptime(end, '%Y-%m-%d').date() return (self._dir_minus_year(d1).strftime('%Y-%m-%d'), self._dir_minus_year(d2).strftime('%Y-%m-%d')) def _dir_kpis(self, start, end, sucursal): """Cifras del resumen del periodo (y de su comparativo).""" V = request.env['skeen.venta'].sudo() C = request.env['skeen.cita'].sudo() P = request.env['skeen.pago'].sudo() vdom = [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)] vdom += self._dir_domain_sucursal('skeen.venta', sucursal) vgrp = V.read_group(vdom, ['total:sum', 'amount_paid:sum', 'amount_due:sum', 'propina:sum'], []) vrow = vgrp[0] if vgrp else {} num_ventas = vrow.get('__count') or vrow.get('total_count') or 0 ventas_total = vrow.get('total') or 0.0 pagado_ventas = vrow.get('amount_paid') or 0.0 propinas = vrow.get('propina') or 0.0 pdom = [('payment_date', '>=', f'{start} 00:00:00'), ('payment_date', '<=', f'{end} 23:59:59'), ('state', '=', 'completed')] pdom += self._dir_domain_sucursal('skeen.pago', sucursal) pgrp = P.read_group(pdom, ['amount:sum'], []) cobros = (pgrp[0].get('amount') or 0.0) if pgrp else 0.0 cdom = [('date', '>=', start), ('date', '<=', end)] cdom += self._dir_domain_sucursal('skeen.cita', sucursal) estados = {} for g in C.read_group(cdom, ['state'], ['state']): estados[g['state']] = g.get('__count') or g.get('state_count') or 0 total_citas = sum(estados.values()) canceladas = estados.get('cancelled', 0) no_show = estados.get('no_show', 0) atendidas = estados.get('done', 0) activas = total_citas - canceladas # Pacientes activos y pacientes nuevos ("primera vez"). La definición de # "primera vez" es EXACTAMENTE la de /reports/kpis: una cita del periodo # cuya fecha es la primera cita histórica no cancelada del paciente. # Se resuelve con consultas agrupadas para no traer miles de citas. cr = request.env.cr params = [start, end] suc_sql = ' AND branch = %s' if sucursal else '' if sucursal: params.append(sucursal) cr.execute("SELECT COUNT(DISTINCT partner_id) FROM skeen_cita " "WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, params) fila = cr.fetchone() pacientes = fila[0] if fila else 0 cr.execute( "SELECT COUNT(*) FROM skeen_cita c " "JOIN (SELECT partner_id, MIN(date) AS f FROM skeen_cita " " WHERE state <> 'cancelled' GROUP BY partner_id) m " " ON m.partner_id = c.partner_id " "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s " " AND c.date = m.f" + (' AND c.branch = %s' if sucursal else ''), params) fila = cr.fetchone() primera = fila[0] if fila else 0 return { 'ingresos_cobrados': round(cobros, 2), 'ventas_total': round(ventas_total, 2), 'ventas_count': num_ventas, 'pagado_en_ventas': round(pagado_ventas, 2), 'ticket_promedio': round(ventas_total / num_ventas, 2) if num_ventas else 0.0, 'propinas': round(propinas, 2), 'visitas_atendidas': atendidas, 'citas_total': total_citas, 'citas_activas': activas, 'pacientes_activos': pacientes, 'pacientes_nuevos': primera, 'pacientes_recurrentes': max(activas - primera, 0), 'tasa_no_show': round(no_show / activas, 4) if activas else 0.0, 'tasa_cancelacion': round(canceladas / total_citas, 4) if total_citas else 0.0, } def _dir_variacion(self, actual, anterior): if anterior in (None, 0): return None return round((actual - anterior) / anterior, 4) def _dir_clientes(self, start, end, sucursal): """Pacientes activos, nuevos por mes, cohortes de retención, frecuencia, valor por cliente y clientes en riesgo.""" cr = request.env.cr cfg = get_direccion(request.env) # Pacientes activos y citas del periodo, con una sola consulta agrupada. params = [start, end] suc_sql = '' if sucursal: suc_sql = ' AND branch = %s' params.append(sucursal) cr.execute( "SELECT COUNT(*), COUNT(DISTINCT partner_id) FROM skeen_cita " "WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, params) n_citas_periodo, n_pac_periodo = cr.fetchone() n_citas_periodo = n_citas_periodo or 0 n_pac_periodo = n_pac_periodo or 0 frecuencia_periodo = (round(n_citas_periodo / n_pac_periodo, 2) if n_pac_periodo else 0.0) # Primera y última visita + visitas por paciente (histórico). Una sola # consulta agrupada: con el volumen real (52k citas) el ORM por separado # (tres read_group) costaba la mayor parte del tiempo del tablero. cr.execute("SELECT partner_id, MIN(date), MAX(date), COUNT(*) FROM skeen_cita " "WHERE state <> 'cancelled' GROUP BY partner_id") mins, maxs, conteos = {}, {}, {} total_citas_hist = 0 for pid, d_min, d_max, n_vis in cr.fetchall(): mins[pid] = d_min maxs[pid] = d_max conteos[pid] = n_vis or 0 total_citas_hist += n_vis or 0 frecuencia_historica = (round(total_citas_hist / len(mins), 2) if mins else 0.0) # Nuevos por mes (últimos 12 meses) hoy = date.today() meses = [self._dir_mes_key(self._dir_add_months(hoy.replace(day=1), -i)) for i in range(11, -1, -1)] nuevos_mes = {m: 0 for m in meses} for pid, d in mins.items(): key = self._dir_mes_key(d) if key in nuevos_mes: nuevos_mes[key] += 1 # Retención por cohortes (últimas 12 cohortes de primera visita) cohort_ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) in nuevos_mes] citas_cohorte = {} if cohort_ids: cr.execute("SELECT partner_id, date FROM skeen_cita " "WHERE state <> 'cancelled' AND partner_id = ANY(%s) " "ORDER BY partner_id, date", (cohort_ids,)) for pid, d_fecha in cr.fetchall(): citas_cohorte.setdefault(pid, []).append(d_fecha) retencion = [] for m in meses: ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) == m] fila = {'cohorte': m, 'tamano': len(ids)} for k in (1, 3, 6, 12): clave = 'ret%d' % k if not ids: fila[clave] = 0 fila[clave + '_tasa'] = None continue primero = min(mins[pid] for pid in ids) maduro = self._dir_add_months(primero, k) <= hoy ret = 0 for pid in ids: tope = self._dir_add_months(mins[pid], k) if any(d > mins[pid] and d <= tope for d in citas_cohorte.get(pid, [])): ret += 1 fila[clave] = ret fila[clave + '_tasa'] = round(ret / len(ids), 4) if maduro else None retencion.append(fila) # Valor por cliente (ingreso histórico por paciente, ventas no canceladas) cr.execute("SELECT SUM(total) FROM skeen_venta " "WHERE state <> 'cancelled' GROUP BY partner_id") valores = sorted(float(r[0] or 0.0) for r in cr.fetchall()) valor_prom = round(sum(valores) / len(valores), 2) if valores else 0.0 valor_mediana = 0.0 if valores: n = len(valores) mitad = n // 2 valor_mediana = round((valores[mitad] if n % 2 else (valores[mitad - 1] + valores[mitad]) / 2.0), 2) # Clientes en riesgo dias = cfg['dias_inactividad'] visitas_min = cfg['visitas_minimas_riesgo'] limite = hoy - timedelta(days=dias) riesgo = [] for pid, ultimo in maxs.items(): if ultimo and ultimo <= limite and conteos.get(pid, 0) >= visitas_min: riesgo.append((pid, ultimo, conteos.get(pid, 0), hoy - ultimo if ultimo else 0)) riesgo.sort(key=lambda x: (-x[2], x[1])) total_riesgo = len(riesgo) top = riesgo[:100] partners = request.env['res.partner'].sudo().browse([r[0] for r in top]) riesgo_rows = [] for partner in partners: ultimo = maxs.get(partner.id) riesgo_rows.append({ 'id': partner.id, 'name': partner.name, 'phone': partner.phone or partner.mobile or partner.whatsapp or '', 'ultima_visita': ultimo.strftime('%Y-%m-%d') if ultimo else None, 'visitas': conteos.get(partner.id, 0), 'dias_sin_venir': (hoy - ultimo).days if ultimo else None, }) return { 'activos': n_pac_periodo, 'frecuencia_promedio': frecuencia_periodo, 'frecuencia_historica': frecuencia_historica, 'nuevos_por_mes': [{'mes': m, 'total': nuevos_mes[m]} for m in meses], 'retencion_cohortes': retencion, 'valor_por_cliente': { 'promedio': valor_prom, 'mediana': valor_mediana, 'total': round(sum(valores), 2), 'pacientes_con_ventas': len(valores), }, 'en_riesgo': { 'total': total_riesgo, 'dias_inactividad': dias, 'visitas_minimas': visitas_min, 'pacientes': riesgo_rows, 'filtro_campana': {'ultima_visita_dias': dias}, 'link_campana': '/campanas', }, } def _dir_ocupacion(self, start, end, sucursal): """Horas vendidas vs disponibles (reglas de /reports/kpis), recursos (reglas de /reports/recursos) y franjas horarias (/reports/horas-agenda).""" cr = request.env.cr 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 not b.doctor_id or 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 params = [start, end] suc_sql = ' AND c.branch = %s' if sucursal else '' if sucursal: params.append(sucursal) # Ocupación por médico / no-show por médico (reglas de /reports/kpis). # El cálculo por horas se hace en SQL agrupado: con rangos largos el # ORM por cita era el cuello de botella del tablero. cr.execute( "SELECT c.doctor_id, COALESCE(e.name, 'Sin médico'), " " SUM(COALESCE(s.duration_min, 30)), COUNT(*), " " COUNT(*) FILTER (WHERE c.state = 'no_show') " "FROM skeen_cita c " "LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " "LEFT JOIN hr_employee e ON e.id = c.doctor_id " "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY c.doctor_id, e.name", params) medicos = [] no_show_medico = {} for doctor_id, doc, minutos, n_citas, n_ns in cr.fetchall(): no_show_medico[doc] = {'citas': n_citas or 0, 'no_shows': n_ns or 0} if not doctor_id: continue # /reports/kpis no cuenta horas de citas sin médico vendidas = (minutos or 0) / 60.0 disponibles = max(dias_habiles * 8.0 - bloqueo_horas.get(doc, 0.0), 0.0) medicos.append({ 'medico': doc, 'horas_vendidas': round(vendidas, 2), 'horas_disponibles': round(disponibles, 2), 'horas_libres': round(max(disponibles - vendidas, 0.0), 2), 'ocupacion': round(min(vendidas / disponibles, 1.0), 4) if disponibles else 0.0, }) medicos.sort(key=lambda x: -x['ocupacion']) no_show_rows = [{ 'medico': k, 'citas': v['citas'], 'no_shows': v['no_shows'], 'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0.0, } for k, v in no_show_medico.items()] no_show_rows.sort(key=lambda x: -x['tasa']) # Ocupación por sucursal (mismas citas; capacidad = jornada de la agenda). # La duración usa la regla de `_duracion()`: cita > servicio > 30 min. ini_h, fin_h = self._agenda_bounds() jornada = max(0.0, fin_h - ini_h) disponibles_suc = dias_habiles * jornada cr.execute( "SELECT c.branch, SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) " "FROM skeen_cita c LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY c.branch", params) por_sucursal = {br or 'sin_sucursal': (minutos or 0) / 60.0 for br, minutos in cr.fetchall()} sucursales = [{ 'sucursal': k, 'horas_vendidas': round(v, 2), 'horas_disponibles': round(disponibles_suc, 2), 'ocupacion': round(min(v / disponibles_suc, 1.0), 4) if disponibles_suc else 0.0, } for k, v in por_sucursal.items()] sucursales.sort(key=lambda x: -x['horas_vendidas']) # Ocupación por recurso (igual que /reports/recursos) horas_dia = jornada disponibles_rec = horas_dia * ((d2 - d1).days + 1) cr.execute( "SELECT rel.recurso_id, COUNT(*), " " SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) " "FROM skeen_cita c " "JOIN skeen_cita_recurso_rel rel ON rel.cita_id = c.id " "LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " "WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') " " AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY rel.recurso_id", params) acumulado = {rid: {'citas': n or 0, 'minutos': m or 0} for rid, n, m in cr.fetchall()} # Franjas horarias (/reports/horas-agenda) cr.execute( "SELECT CAST(FLOOR(c.time) AS INT), c.state, COUNT(*) FROM skeen_cita c " "WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') " " AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY 1, 2", params) franjas = {} for hora, estado, n in cr.fetchall(): fr = franjas.setdefault(int(hora), {'total': 0, 'by_state': {}}) fr['total'] += n or 0 fr['by_state'][estado] = fr['by_state'].get(estado, 0) + (n or 0) recursos = request.env['skeen.recurso'].sudo().search( [('activo', '=', True)], order='sequence, name') filas_rec = [] for r in recursos: acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0}) horas = round(acc['minutos'] / 60.0, 2) ocup = round(horas / disponibles_rec * 100.0, 1) if disponibles_rec else 0.0 filas_rec.append({ 'recurso_id': r.id, 'recurso': r.name, 'citas': acc['citas'], 'horas_ocupadas': horas, 'horas_disponibles': round(disponibles_rec, 2), 'ocupacion_pct': min(ocup, 100.0), }) filas_hora = [] for hora in range(8, 20): entry = franjas.get(hora, {'total': 0, 'by_state': {}}) filas_hora.append({'hora': f'{hora:02d}:00', 'total': entry['total'], 'by_state': entry['by_state']}) con_citas = [f for f in filas_hora if f['total'] > 0] mas = sorted(con_citas, key=lambda x: -x['total'])[:3] menos = sorted(filas_hora, key=lambda x: x['total'])[:3] return { 'dias_habiles': dias_habiles, 'jornada_horas': round(jornada, 2), 'por_medico': medicos, 'no_show_por_medico': no_show_rows, 'por_sucursal': sucursales, 'por_recurso': filas_rec, 'franjas': filas_hora, 'franjas_mas_ocupadas': mas, 'franjas_menos_ocupadas': menos, 'total_horas_vendidas': round(sum(m['horas_vendidas'] for m in medicos), 2), 'total_horas_ocupadas_recursos': round(sum(f['horas_ocupadas'] for f in filas_rec), 2), } def _dir_category_label(self, servicio, code): """Etiqueta de la categoría de un servicio (la selección puede ser una lista o un método, según cómo esté definida).""" if not servicio or not code: return '' try: sel = servicio._fields['category'].selection if isinstance(sel, str): sel = getattr(servicio, sel)() elif callable(sel): sel = sel(servicio) return dict(sel).get(code, '') or '' except Exception: return '' def _dir_ingresos(self, start, end, sucursal): """Ingresos por servicio, categoría, sucursal, método de pago, propinas, membresías, paquetes y pasivo de lealtad.""" P = request.env['skeen.pago'].sudo() cr = request.env.cr params = [start, end] suc_sql = ' AND c.branch = %s' if sucursal else '' if sucursal: params.append(sucursal) # Por servicio y por categoría: líneas de las ventas del periodo, en una # sola consulta agrupada (no se traen las ventas al proceso). cr.execute( "SELECT l.service_id, SUM(l.subtotal), SUM(l.quantity) " "FROM skeen_venta_line l JOIN skeen_venta v ON v.id = l.venta_id " "LEFT JOIN skeen_cita c ON c.id = v.cita_id " "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + suc_sql + " GROUP BY l.service_id", params) servicios = [{ 'service_id': sid or 0, 'servicio': 'Sin servicio', 'cantidad': float(qty or 0.0), 'total': round(float(subtotal or 0.0), 2), } for sid, subtotal, qty in cr.fetchall()] categorias = {} svc_ids = [s['service_id'] for s in servicios if s['service_id']] svc_map = {x.id: x for x in request.env['skeen.servicio'].sudo().browse(svc_ids)} for s in servicios: svc = svc_map.get(s['service_id']) s['servicio'] = svc.name if svc else 'Sin servicio' code = svc.category if svc else '' label = self._dir_category_label(svc, code) if svc else '' cat = categorias.setdefault(code or 'sin', {'categoria': label or 'Sin categoría', 'total': 0.0, 'cantidad': 0.0}) cat['total'] += s['total'] cat['cantidad'] += s['cantidad'] servicios.sort(key=lambda x: -x['total']) # Por sucursal (la sucursal sale de la cita origen de la venta) cr.execute( "SELECT c.branch, SUM(v.total), COUNT(*) FROM skeen_venta v " "LEFT JOIN skeen_cita c ON c.id = v.cita_id " "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + suc_sql + " GROUP BY c.branch", params) por_sucursal = {} for branch, total, n in cr.fetchall(): por_sucursal[branch or 'sin_sucursal'] = { 'total': float(total or 0.0), 'ventas': n or 0} # Por método de pago (igual que /reports/cash) pdom = [('payment_date', '>=', f'{start} 00:00:00'), ('payment_date', '<=', f'{end} 23:59:59'), ('state', '=', 'completed')] pdom += self._dir_domain_sucursal('skeen.pago', sucursal) por_metodo = {} for g in P.read_group(pdom, ['payment_method', 'amount:sum'], ['payment_method']): por_metodo[g['payment_method'] or 'otro'] = round(g.get('amount') or 0.0, 2) # Membresías Memb = request.env['skeen.membresia'].sudo() activas_grp = Memb.read_group([('estado', 'in', ('activa', 'congelada'))], ['plan_id'], ['plan_id']) FACTOR = {'semanal': 52 / 12.0, 'mensual': 1.0, 'trimestral': 1 / 3.0, 'semestral': 1 / 6.0, 'anual': 1 / 12.0} mrr = 0.0 for g in activas_grp: if not g.get('plan_id'): continue plan = request.env['skeen.membresia.plan'].sudo().browse(g['plan_id'][0]) n = g.get('__count') or g.get('plan_id_count') or 0 mrr += n * (plan.precio or 0.0) * FACTOR.get(plan.periodicidad, 1.0) Periodo = request.env['skeen.membresia.periodo'].sudo() recurrentes = Periodo.read_group([ ('estado', '=', 'pagado'), ('tipo', '=', 'periodo'), ('fecha_pago', '>=', f'{start} 00:00:00'), ('fecha_pago', '<=', f'{end} 23:59:59')], ['monto:sum'], []) membresias = { 'activas': sum((g.get('__count') or g.get('plan_id_count') or 0) for g in activas_grp), 'mrr_estimado': round(mrr, 2), 'renovaciones': sum((g.get('__count') or 0) for g in recurrentes), 'ingreso_recurrente_periodo': round((recurrentes[0].get('monto') or 0.0) if recurrentes else 0.0, 2), 'altas': Memb.search_count([('fecha_inicio', '>=', start), ('fecha_inicio', '<=', end)]), 'cancelaciones': Memb.search_count([ ('estado', '=', 'cancelada'), ('fecha_cancelacion', '>=', f'{start} 00:00:00'), ('fecha_cancelacion', '<=', f'{end} 23:59:59')]), } # Paquetes vendidos y sesiones pendientes (compromiso del negocio) PC = request.env['skeen.paquete.compra'].sudo() pv = PC.read_group([('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled')], ['price:sum'], []) ses = PC.read_group([('state', '!=', 'cancelled')], ['sessions_total:sum', 'sessions_used:sum'], []) paquetes = { 'vendidos': (PC.search_count([('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled')])), 'total_vendido': round((pv[0].get('price') or 0.0) if pv else 0.0, 2), 'activos': PC.search_count([('state', '=', 'active')]), 'sesiones_pendientes': int(((ses[0].get('sessions_total') or 0) - (ses[0].get('sessions_used') or 0)) if ses else 0), } # Lealtad: pasivo en pesos (igual que /reports/lealtad) cfg_l = get_lealtad(request.env) valor = float(cfg_l.get('valor_punto') or 1.0) mon = request.env['skeen.monedero'].sudo().read_group([], ['points:sum'], []) puntos = int((mon[0].get('points') or 0) if mon else 0) Tx = request.env['skeen.monedero.transaction'].sudo() by_type = {} for g in Tx.read_group([('create_date', '>=', f'{start} 00:00:00'), ('create_date', '<=', f'{end} 23:59:59')], ['type', 'points:sum'], ['type']): by_type[g['type']] = g['points'] lealtad = { 'valor_punto': valor, 'puntos_activos': puntos, 'pasivo_mxn': round(puntos * valor, 2), 'otorgados': by_type.get('accrual', 0) + by_type.get('deposito', 0), 'canjeados': abs(by_type.get('redemption', 0)), 'expirados': abs(by_type.get('expiration', 0)), } return { 'por_servicio': servicios, 'por_categoria': sorted(categorias.values(), key=lambda x: -x['total']), 'por_sucursal': [{'sucursal': k, 'total': round(v['total'], 2), 'ventas': v['ventas']} for k, v in por_sucursal.items()], 'por_metodo_pago': por_metodo, 'membresias': membresias, 'paquetes': paquetes, 'lealtad': lealtad, } def _dir_metas(self, start, end, sucursal, kpis=None): """Metas por sucursal y por empleado, avance del mes y proyección.""" cfg = get_direccion(request.env) V = request.env['skeen.venta'].sudo() Lineas = request.env['skeen.venta.line'].sudo() hoy = date.today() d_fin = datetime.strptime(end, '%Y-%m-%d').date() mes_ini = d_fin.replace(day=1) mes_fin = d_fin.replace(day=calendar.monthrange(d_fin.year, d_fin.month)[1]) dias_mes = (mes_fin - mes_ini).days + 1 dias_transcurridos = min((min(hoy, mes_fin) - mes_ini).days + 1, dias_mes) if dias_transcurridos < 1: dias_transcurridos = 1 mes_cerrado = hoy > mes_fin def proyectar(actual): return round(actual, 2) if mes_cerrado else round(actual / dias_transcurridos * dias_mes, 2) # Metas por sucursal metas = {m['sucursal']: m['meta_mensual'] for m in cfg['metas_sucursal']} sucursales = request.env['skeen.sucursal'].sudo().search([], order='sequence, name') codigos = [s.code for s in sucursales if s.code] for code in metas: if code not in codigos: codigos.append(code) filas_suc = [] for code in codigos: grp = V.read_group([('state', '!=', 'cancelled'), ('date', '>=', mes_ini.strftime('%Y-%m-%d')), ('date', '<=', mes_fin.strftime('%Y-%m-%d')), ('cita_id.branch', '=', code)], ['total:sum'], []) actual = round((grp[0].get('total') or 0.0) if grp else 0.0, 2) meta = metas.get(code, 0.0) filas_suc.append({ 'sucursal': code, 'meta_mensual': round(meta, 2), 'ventas_mes': actual, 'pct': round(actual / meta, 4) if meta else None, 'proyeccion': proyectar(actual), }) # Metas por empleado (ventas del mes atribuidas por línea recetada) mes_ventas = V.search([('state', '!=', 'cancelled'), ('date', '>=', mes_ini.strftime('%Y-%m-%d')), ('date', '<=', mes_fin.strftime('%Y-%m-%d'))]) vendido_emp = {} if mes_ventas: for g in Lineas.read_group([('venta_id', 'in', mes_ventas.ids), ('prescribed_by_id', '!=', False)], ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']): vendido_emp[g['prescribed_by_id'][0]] = g.get('subtotal') or 0.0 Emp = request.env['hr.employee'].sudo() empleados = Emp.search(['|', ('monthly_goal', '>', 0), ('id', 'in', list(vendido_emp.keys()))]) filas_emp = [] for e in empleados: actual = round(vendido_emp.get(e.id, 0.0), 2) meta = e.monthly_goal or 0.0 filas_emp.append({ 'employee_id': e.id, 'empleado': e.name, 'meta_mensual': round(meta, 2), 'ventas_mes': actual, 'pct': round(actual / meta, 4) if meta else None, 'proyeccion': proyectar(actual), }) filas_emp.sort(key=lambda x: -(x['ventas_mes'])) # Proyección del periodo seleccionado p1 = datetime.strptime(start, '%Y-%m-%d').date() p2 = d_fin dias_periodo = (p2 - p1).days + 1 dias_trans_p = min((min(hoy, p2) - p1).days + 1, dias_periodo) if dias_trans_p < 1: dias_trans_p = 1 if kpis is None: kpis = self._dir_kpis(start, end, sucursal) periodo_cerrado = hoy > p2 proy_ventas = (kpis['ventas_total'] if periodo_cerrado else round(kpis['ventas_total'] / dias_trans_p * dias_periodo, 2)) proy_cobros = (kpis['ingresos_cobrados'] if periodo_cerrado else round(kpis['ingresos_cobrados'] / dias_trans_p * dias_periodo, 2)) # Mes actual (para metas) mes_grp = V.read_group([('state', '!=', 'cancelled'), ('date', '>=', mes_ini.strftime('%Y-%m-%d')), ('date', '<=', mes_fin.strftime('%Y-%m-%d'))], ['total:sum'], []) ventas_mes = round((mes_grp[0].get('total') or 0.0) if mes_grp else 0.0, 2) meta_global = cfg['meta_mensual_global'] or sum(metas.values()) return { 'por_sucursal': filas_suc, 'por_empleado': filas_emp, 'periodo': { 'dias': dias_periodo, 'dias_transcurridos': dias_trans_p, 'ventas': kpis['ventas_total'], 'cobros': kpis['ingresos_cobrados'], 'proyeccion_ventas': proy_ventas, 'proyeccion_cobros': proy_cobros, }, 'mes': { 'mes': self._dir_mes_key(mes_ini), 'dias_mes': dias_mes, 'dias_transcurridos': dias_transcurridos, 'ventas': ventas_mes, 'meta': round(meta_global, 2), 'pct': round(ventas_mes / meta_global, 4) if meta_global else None, 'proyeccion': proyectar(ventas_mes), 'proyeccion_vs_meta': (round(proyectar(ventas_mes) / meta_global, 4) if meta_global else None), }, } @http.route('/skeen/frontend/v1/direccion', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('direccion.ver') def direccion_tablero(self, **kw): """Tablero ejecutivo del dueño. Solo lectura.""" try: if request.httprequest.method == 'OPTIONS': return json_response({}, 200) hoy = date.today().strftime('%Y-%m-%d') start = kw.get('start') or hoy end = kw.get('end') or hoy try: d1 = datetime.strptime(start, '%Y-%m-%d').date() d2 = datetime.strptime(end, '%Y-%m-%d').date() except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400) if d2 < d1: d1, d2 = d2, d1 start, end = d1.strftime('%Y-%m-%d'), d2.strftime('%Y-%m-%d') if (d2 - d1).days > 4000: return json_response({'status': 'error', 'message': 'El rango no puede pasar de 4000 días'}, 400) sucursal = (kw.get('sucursal') or '').strip() or None cfg = get_direccion(request.env) comparar = (kw.get('comparar') or cfg['comparativo_default'] or 'anterior').lower() if comparar not in ('anterior', 'anio', 'ninguno'): comparar = 'anterior' t0 = datetime.now() kpis = self._dir_kpis(start, end, sucursal) # Serie diaria de ventas (mismo criterio que /reports/sales.by_day) params = [start, end] suc_sql = ' AND c.branch = %s' if sucursal else '' if sucursal: params.append(sucursal) request.env.cr.execute( "SELECT v.date, SUM(v.total) FROM skeen_venta v " "LEFT JOIN skeen_cita c ON c.id = v.cita_id " "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + suc_sql + " GROUP BY v.date ORDER BY v.date", params) by_day = {str(d)[:10]: round(float(t or 0.0), 2) for d, t in request.env.cr.fetchall()} kpis['por_dia'] = [{'date': k, 'total': by_day[k]} for k in sorted(by_day)] resumen = kpis comparativo = None if comparar != 'ninguno': if comparar == 'anio': cs, ce = self._dir_periodo_anio(start, end) else: cs, ce = self._dir_periodo_anterior(start, end) ck = self._dir_kpis(cs, ce, sucursal) variaciones = {} for campo in ('ingresos_cobrados', 'ventas_total', 'ticket_promedio', 'visitas_atendidas', 'pacientes_nuevos', 'pacientes_recurrentes', 'tasa_no_show', 'tasa_cancelacion'): variaciones[campo] = self._dir_variacion(kpis.get(campo, 0), ck.get(campo, 0)) comparativo = {'modo': comparar, 'start': cs, 'end': ce, 'kpis': ck, 'variacion': variaciones} t_sec = {'resumen': int((datetime.now() - t0).total_seconds() * 1000)} t_a = datetime.now() clientes = self._dir_clientes(start, end, sucursal) t_b = datetime.now() ocupacion = self._dir_ocupacion(start, end, sucursal) t_c = datetime.now() ingresos = self._dir_ingresos(start, end, sucursal) t_d = datetime.now() metas = self._dir_metas(start, end, sucursal, kpis=kpis) t_e = datetime.now() t_sec['clientes'] = int((t_b - t_a).total_seconds() * 1000) t_sec['ocupacion'] = int((t_c - t_b).total_seconds() * 1000) t_sec['ingresos'] = int((t_d - t_c).total_seconds() * 1000) t_sec['metas'] = int((t_e - t_d).total_seconds() * 1000) no_disponible = [ {'clave': 'entregas', 'etiqueta': 'Entregas', 'motivo': 'El sistema no registra entregas de productos, así que no hay dato.'}, {'clave': 'whatsapp_resultados', 'etiqueta': 'Resultados de WhatsApp', 'motivo': 'No hay una métrica de resultados de WhatsApp disponible en este tablero.'}, ] ms = int((datetime.now() - t0).total_seconds() * 1000) respuesta = { 'status': 'success', 'start': start, 'end': end, 'sucursal': sucursal, 'comparativo': comparativo, 'resumen': resumen, 'clientes': clientes, 'ocupacion': ocupacion, 'ingresos': ingresos, 'metas': metas, 'no_disponible': no_disponible, 'ms': ms, } if kw.get('debug') in ('1', 'true'): respuesta['_ms_secciones'] = t_sec return json_response(respuesta) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Sucursales (configurables) # ============================================================ def _sucursal_to_dict(self, s): return { 'id': s.id, 'code': s.code, 'name': s.name, 'sequence': s.sequence, 'active': s.active, 'is_default': s.is_default, 'address': s.address or '', 'phone': s.phone or '', 'notes': s.notes or '', } def _marcar_sucursal_default(self, sucursal): """Como mucho una sucursal predeterminada.""" otras = request.env['skeen.sucursal'].sudo().with_context( active_test=False).search( [('is_default', '=', True), ('id', '!=', sucursal.id)]) if otras: otras.write({'is_default': False}) @http.route('/skeen/frontend/v1/sucursales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_sucursales(self, **kw): """Sucursales. Por defecto solo activas; ?include_inactive=1 las trae todas.""" try: incluir = kw.get('include_inactive') in ('1', 'true', 'True') Sucursal = request.env['skeen.sucursal'].sudo() if incluir: Sucursal = Sucursal.with_context(active_test=False) recs = Sucursal.search([], order='sequence, name') return json_response({'status': 'success', 'sucursales': [self._sucursal_to_dict(s) for s in recs], 'total': len(recs)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sucursales', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def create_sucursal(self, **kw): """Crea una sucursal. Sin `code` se genera un slug del nombre.""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) Sucursal = request.env['skeen.sucursal'].sudo() codigo = (data.get('code') or '').strip() if codigo: codigo = catalog_slugify(codigo) or codigo if Sucursal.with_context(active_test=False).search_count([('code', '=', codigo)]): return json_response({ 'status': 'error', 'message': 'Ya existe una sucursal con el código %s' % codigo}, 409) else: codigo = catalog_unique_slug(Sucursal, name) try: sequence = int(data.get('sequence') or 10) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) sucursal = Sucursal.create({ 'name': name, 'code': codigo, 'sequence': sequence, 'active': bool(data.get('active', True)), 'is_default': bool(data.get('is_default', False)), 'address': data.get('address') or '', 'phone': data.get('phone') or '', 'notes': data.get('notes') or '', }) if sucursal.is_default: self._marcar_sucursal_default(sucursal) self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'crear', new_value=json.dumps(self._sucursal_to_dict(sucursal), ensure_ascii=False)[:500], entity_label='Sucursal %s' % sucursal.name, notes=(data.get('motivo') or 'creada').strip()) return json_response({'status': 'success', 'sucursal': self._sucursal_to_dict(sucursal)}, 201) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sucursales/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def update_sucursal(self, sucursal_id, **kw): try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id) if not sucursal.exists(): return json_response({'status': 'error', 'message': 'Sucursal no encontrada'}, 404) data = _parse_json_body() vals = {} if 'name' in data and str(data.get('name') or '').strip(): vals['name'] = str(data['name']).strip() if 'code' in data: codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip() if codigo and codigo != sucursal.code: dup = request.env['skeen.sucursal'].sudo().with_context( active_test=False).search_count( [('code', '=', codigo), ('id', '!=', sucursal.id)]) if dup: return json_response({ 'status': 'error', 'message': 'Ya existe una sucursal con el código %s' % codigo}, 409) vals['code'] = codigo for campo in ('address', 'phone', 'notes'): if campo in data: vals[campo] = data.get(campo) or '' if 'sequence' in data: try: vals['sequence'] = int(data['sequence']) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) if 'active' in data: vals['active'] = bool(data['active']) if 'is_default' in data: vals['is_default'] = bool(data['is_default']) if not vals: return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) sucursal.write(vals) if sucursal.is_default: self._marcar_sucursal_default(sucursal) self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'editar', new_value=json.dumps(self._sucursal_to_dict(sucursal), ensure_ascii=False)[:500], entity_label='Sucursal %s' % sucursal.name, notes=(data.get('motivo') or 'actualizada').strip()) return json_response({'status': 'success', 'sucursal': self._sucursal_to_dict(sucursal)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sucursales/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def delete_sucursal(self, sucursal_id, **kw): """Desactiva la sucursal (nunca se borra: las citas la referencian).""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id) if not sucursal.exists(): return json_response({'status': 'error', 'message': 'Sucursal no encontrada'}, 404) en_uso = request.env['skeen.cita'].sudo().search_count( [('branch', '=', sucursal.code)]) sucursal.write({'active': False, 'is_default': False}) self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'desactivar', old_value=json.dumps(self._sucursal_to_dict(sucursal), ensure_ascii=False)[:500], entity_label='Sucursal %s' % sucursal.name, notes='desactivada (%d citas)' % en_uso) if en_uso: mensaje = ('La sucursal tiene %d cita(s) registradas y no se puede ' 'eliminar; quedó desactivada.' % en_uso) else: mensaje = 'Sucursal desactivada.' return json_response({'status': 'success', 'sucursal': self._sucursal_to_dict(sucursal), 'en_uso': en_uso, 'message': mensaje}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Categorías de servicio (configurables) # ============================================================ def _service_category_to_dict(self, c): return { 'id': c.id, 'code': c.code, 'name': c.name, 'sequence': c.sequence, 'active': c.active, 'is_system': c.is_system, } @http.route('/skeen/frontend/v1/service-categories', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_service_categories(self, **kw): """Categorías de servicio. ?include_inactive=1 trae también las inactivas.""" try: incluir = kw.get('include_inactive') in ('1', 'true', 'True') Category = request.env['skeen.service.category'].sudo() if incluir: Category = Category.with_context(active_test=False) recs = Category.search([], order='sequence, name') return json_response({'status': 'success', 'categories': [self._service_category_to_dict(c) for c in recs], 'total': len(recs)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/service-categories', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def create_service_category(self, **kw): """Crea una categoría (las creadas por API nunca son de sistema).""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) Category = request.env['skeen.service.category'].sudo() codigo = (data.get('code') or '').strip() if codigo: codigo = catalog_slugify(codigo) or codigo if Category.with_context(active_test=False).search_count([('code', '=', codigo)]): return json_response({ 'status': 'error', 'message': 'Ya existe una categoría con el código %s' % codigo}, 409) else: codigo = catalog_unique_slug(Category, name) try: sequence = int(data.get('sequence') or 10) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) categoria = Category.create({ 'name': name, 'code': codigo, 'sequence': sequence, 'active': bool(data.get('active', True)), 'is_system': False, }) self._auditar('catalogos', 'service-category:%s' % categoria.id, 'crear', new_value=json.dumps(self._service_category_to_dict(categoria), ensure_ascii=False)[:500], entity_label='Categoría %s' % categoria.name, notes=(data.get('motivo') or 'creada').strip()) return json_response({'status': 'success', 'category': self._service_category_to_dict(categoria)}, 201) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/service-categories/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def update_service_category(self, category_id, **kw): """Edita una categoría. `is_system` no se puede cambiar.""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) categoria = request.env['skeen.service.category'].sudo().browse(category_id) if not categoria.exists(): return json_response({'status': 'error', 'message': 'Categoría no encontrada'}, 404) data = _parse_json_body() vals = {} if 'name' in data and str(data.get('name') or '').strip(): vals['name'] = str(data['name']).strip() if 'code' in data: codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip() if codigo and codigo != categoria.code: dup = request.env['skeen.service.category'].sudo().with_context( active_test=False).search_count( [('code', '=', codigo), ('id', '!=', categoria.id)]) if dup: return json_response({ 'status': 'error', 'message': 'Ya existe una categoría con el código %s' % codigo}, 409) vals['code'] = codigo if 'sequence' in data: try: vals['sequence'] = int(data['sequence']) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) if 'active' in data: vals['active'] = bool(data['active']) if not vals: return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) categoria.write(vals) self._auditar('catalogos', 'service-category:%s' % categoria.id, 'editar', new_value=json.dumps(self._service_category_to_dict(categoria), ensure_ascii=False)[:500], entity_label='Categoría %s' % categoria.name, notes=(data.get('motivo') or 'actualizada').strip()) return json_response({'status': 'success', 'category': self._service_category_to_dict(categoria)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/service-categories/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') @require_perm('config.editar') def delete_service_category(self, category_id, **kw): """Desactiva la categoría. Las de sistema no se pueden eliminar.""" try: if not self._puede_configurar_catalogo(): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) categoria = request.env['skeen.service.category'].sudo().browse(category_id) if not categoria.exists(): return json_response({'status': 'error', 'message': 'Categoría no encontrada'}, 404) if categoria.is_system: return json_response({ 'status': 'error', 'message': ('"%s" es una categoría del sistema: no se puede ' 'eliminar, solo renombrar.' % categoria.name)}, 400) en_uso = request.env['skeen.servicio'].sudo().search_count( [('category', '=', categoria.code)]) categoria.write({'active': False}) self._auditar('catalogos', 'service-category:%s' % categoria.id, 'desactivar', old_value=json.dumps(self._service_category_to_dict(categoria), ensure_ascii=False)[:500], entity_label='Categoría %s' % categoria.name, notes='desactivada (%d servicios)' % en_uso) mensaje = 'Categoría desactivada.' if en_uso: mensaje = ('La categoría tiene %d servicio(s) y no se puede eliminar; ' 'quedó desactivada.' % en_uso) return json_response({'status': 'success', 'category': self._service_category_to_dict(categoria), 'en_uso': en_uso, 'message': mensaje}) 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') @require_perm('config.editar') 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') @require_perm('config.editar') 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') @require_perm('config.editar') 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, 'costo_promedio': getattr(it, 'costo_promedio', 0.0), 'ultimo_costo': getattr(it, 'ultimo_costo', 0.0), 'margen': getattr(it, 'margen', 0.0), 'margen_pct': getattr(it, 'margen_pct', 0.0), '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, 'tax_rate': it.tax_rate, 'tax_use_default': it.tax_use_default, '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 '', 'lote': getattr(m, 'lote', '') or '', 'fecha_caducidad': m.fecha_caducidad.strftime('%Y-%m-%d') if getattr(m, 'fecha_caducidad', None) else None, } @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') @require_perm('inventario.mover') @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', ''), } if 'tax_use_default' in data: vals['tax_use_default'] = bool(data.get('tax_use_default')) if 'tax_rate' in data and data.get('tax_rate') is not None: vals['tax_rate'] = float(data.get('tax_rate') or 0) item = request.env['skeen.inventario.item'].sudo().create(vals) return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') @require_perm('inventario.ajustar') @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', 'tax_rate', 'tax_use_default', 'costo_promedio', 'ultimo_costo'] vals = {} for k in allowed: if k in data: vals[k] = data[k] for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate', 'costo_promedio', 'ultimo_costo'): if f in vals: vals[f] = float(vals[f] or 0) if 'tax_use_default' in vals: vals['tax_use_default'] = bool(vals['tax_use_default']) item.write(vals) return json_response({'status': 'success', 'item': self._item_to_dict(item)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items//moves', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def inventory_moves(self, item_id, **kw): try: moves = request.env['skeen.inventario.move'].sudo().search([('item_id', '=', item_id)], order='date desc', limit=100) return json_response({'status': 'success', 'moves': [self._move_to_dict(m) for m in moves]}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items//moves', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') @require_perm('inventario.mover') @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') @require_perm('inventario.mover') @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') @require_perm('inventario.mover') @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/', 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') @require_perm('inventario.ajustar') @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) # ============================================================ # Notificaciones y recordatorios de citas # # Las plantillas y el resto de la configuración viven en el bloque # `notificaciones` de clinic-settings (GET/PUT /clinic-settings), no en # endpoints aparte: así hay una sola fuente de verdad. # ============================================================ def _notif_to_dict(self, n): return { 'id': n.id, 'partner_id': n.partner_id.id, 'patient': n.partner_id.name or '', 'phone': n.partner_id.whatsapp or n.partner_id.phone or '', 'email': n.partner_id.email or '', 'cita_id': n.cita_id.id if n.cita_id else None, 'cita_ref': n.cita_id.name if n.cita_id else '', 'tipo': n.tipo, 'tipo_label': dict(n._fields['tipo'].selection).get(n.tipo, n.tipo), 'canal': n.canal, 'canal_label': dict(n._fields['canal'].selection).get(n.canal, n.canal), 'estado': n.estado, 'estado_label': dict(n._fields['estado'].selection).get(n.estado, n.estado), 'asunto': n.asunto or '', 'mensaje': n.mensaje or '', 'programada_para': n.programada_para.strftime('%Y-%m-%d %H:%M') if n.programada_para else None, 'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None, 'error': n.error or '', 'intentos': n.intentos, } @http.route('/skeen/frontend/v1/notificaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('notificaciones.ver') def list_notificaciones(self, **kw): """Historial de notificaciones con filtros y resumen por estado.""" try: domain = [] try: if kw.get('cita_id'): domain.append(('cita_id', '=', int(kw['cita_id']))) if kw.get('partner_id'): domain.append(('partner_id', '=', int(kw['partner_id']))) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'cita_id / partner_id deben ser numéricos'}, 400) for campo in ('estado', 'tipo', 'canal'): if kw.get(campo): domain.append((campo, '=', kw[campo])) if kw.get('start'): domain.append(('programada_para', '>=', '%s 00:00:00' % kw['start'])) if kw.get('end'): domain.append(('programada_para', '<=', '%s 23:59:59' % kw['end'])) Notif = request.env['skeen.notificacion'].sudo() try: page = max(int(kw.get('page', 1) or 1), 1) page_size = int(kw.get('page_size') or kw.get('limit') or 50) except (TypeError, ValueError): page, page_size = 1, 50 page_size = min(max(page_size, 1), 200) total = Notif.search_count(domain) registros = Notif.search(domain, order='programada_para desc, id desc', limit=page_size, offset=(page - 1) * page_size) resumen = {estado: 0 for estado, _l in Notif._fields['estado'].selection} for grupo in Notif.read_group(domain, ['estado'], ['estado'], lazy=False): estado = grupo.get('estado') if estado: resumen[estado] = grupo.get('__count', grupo.get('estado_count', 0)) return json_response({ 'status': 'success', 'notificaciones': [self._notif_to_dict(n) for n in registros], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'resumen': resumen, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _procesar_notificaciones(self, limite): """Corre el mismo ciclo que el cron y devuelve los conteos.""" return request.env['skeen.notificacion.service'].sudo().ejecutar(limite=limite) @http.route('/skeen/frontend/v1/notificaciones/procesar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('notificaciones.enviar') def procesar_notificaciones(self, **kw): """Fuerza el procesamiento: encola lo que toca y envía la cola.""" try: data = _parse_json_body() try: limite = int(data.get('limit') or 100) except (TypeError, ValueError): limite = 100 limite = min(max(limite, 1), 500) resultado = self._procesar_notificaciones(limite) return json_response({ 'status': 'success', 'message': 'Se encolaron %s y se procesaron %s notificaciones.' % (resultado['encoladas'], resultado['revisadas']), **resultado, }) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/notificaciones/enviar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('notificaciones.enviar') def enviar_notificacion(self, **kw): """Encola y envía un mensaje puntual desde el detalle de la cita.""" try: data = _parse_json_body() cita_id = data.get('cita_id') if not cita_id: return json_response({'status': 'error', 'message': 'Falta cita_id'}, 400) canal = data.get('canal') or None if canal not in (None, 'whatsapp', 'email'): return json_response({'status': 'error', 'message': 'El canal debe ser whatsapp o email'}, 400) cita = request.env['skeen.cita'].sudo().browse(int(cita_id)) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) service = request.env['skeen.notificacion.service'].sudo() cfg = service._cfg() if not cfg['activo']: return json_response({'status': 'error', 'message': 'Las notificaciones están desactivadas'}, 400) creadas = service.encolar_manual(cita, canal=canal) if not creadas: return json_response({'status': 'error', 'message': 'El paciente no tiene destino para ese canal '}, 400) estados = {} for notif in creadas: estados[service._intentar(notif, cfg)] = True return json_response({ 'status': 'success', 'notificaciones': [self._notif_to_dict(n) for n in creadas], 'simulado': bool(cfg['modo_prueba']), 'message': ('Modo prueba: el mensaje quedó registrado como simulado.' if cfg['modo_prueba'] else 'Mensaje enviado.'), }) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/notificaciones//reintentar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('notificaciones.enviar') def reintentar_notificacion(self, notif_id, **kw): """Devuelve a la cola una notificación fallida e intenta de nuevo.""" try: notif = request.env['skeen.notificacion'].sudo().browse(notif_id) if not notif.exists(): return json_response({'status': 'error', 'message': 'Notificación no encontrada'}, 404) service = request.env['skeen.notificacion.service'].sudo() notif.action_reintentar() estado = service._intentar(notif, service._cfg()) return json_response({'status': 'success', 'estado': estado, 'notificacion': self._notif_to_dict(notif)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/notificaciones/probar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('notificaciones.editar') def probar_notificacion(self, **kw): """Prueba de conexión de un canal. Nunca lanza: responde 200 siempre.""" try: data = _parse_json_body() service = request.env['skeen.notificacion.service'].sudo() resultado = service.probar_canal(data.get('canal'), data.get('destino') or '') return json_response({'status': 'success', **resultado}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # Importar fields para fechas from odoo import fields