# -*- coding: utf-8 -*- from odoo import http from odoo.exceptions import ValidationError from odoo.http import request, Response import base64 import json import re from datetime import date, datetime, timedelta from .auth import sign_token, current_user, require_role, verify_token from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles def json_response(data, status=200): """Helper para devolver JSON con CORS headers""" return Response( json.dumps(data, default=str), status=status, mimetype='application/json', headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', } ) def _parse_json_body(): """Lee y parsea el body JSON de la petición""" try: body = request.httprequest.get_data(as_text=True) return json.loads(body) if body else {} except Exception: return {} class SkeenFrontendController(http.Controller): """Controladores API públicos para el Frontend React de SKEEN""" # Horario de agenda de la clínica (hora de inicio permitida de una cita) AGENDA_START = 8.0 # 8:00 AM AGENDA_END = 19.0 # 7:00 PM def _valid_agenda_time(self, value): """Valida hora de inicio dentro del horario de agenda (8:00-19:00).""" try: t = float(value) except (TypeError, ValueError): return False return self.AGENDA_START <= t < self.AGENDA_END # ------------------------------------------------------------------ # Auditoría de configuración # ------------------------------------------------------------------ def _fu_user(self): """Usuario del frontend que hizo la petición (o None).""" return getattr(request, 'frontend_user', None) def _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 (rol medico).""" d = user.to_public_dict() if user.role == 'medico': 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('admin') def auth_users_list(self, **kw): try: users = request.env['skeen.frontend.user'].sudo().search([], order='name, login') return json_response({'status': 'success', 'users': [u.to_public_dict() | {'active': u.active} for u in users]}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') def auth_users_create(self, **kw): try: data = _parse_json_body() login = (data.get('login') or '').strip().lower() name = (data.get('name') or '').strip() password = data.get('password') or '' role = data.get('role') or 'recepcion' if not login or not name: return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400) if len(password) < 8: return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400) if role not in ROLE_KEYS: return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) 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('admin') def auth_users_update(self, user_id, **kw): try: user = request.env['skeen.frontend.user'].sudo().browse(user_id) if not user.exists(): return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) data = _parse_json_body() vals = {} if 'name' in data: vals['name'] = data['name'] if 'role' in data: if data['role'] not in ROLE_KEYS: return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) vals['role'] = data['role'] if 'active' in data: vals['active'] = bool(data['active']) if 'must_change_password' in data: vals['must_change_password'] = bool(data['must_change_password']) if 'allowed_menus' in data: menus = data.get('allowed_menus') if menus is not None and not isinstance(menus, list): return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400) vals['allowed_menus'] = json.dumps(menus) if menus else False if vals: 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('admin') def auth_users_delete(self, user_id, **kw): try: user = request.env['skeen.frontend.user'].sudo().browse(user_id) if not user.exists(): return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) if request.frontend_user and request.frontend_user.id == user.id: return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400) etiqueta = f'{user.login} ({user.name})' user.unlink() self._auditar('usuarios', f'usuario:{user_id}', 'baja', etiqueta, '', entity_label=etiqueta, notes='Usuario eliminado') return json_response({'status': 'success', 'message': 'Usuario eliminado'}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Permisos de menú por rol (matriz dinámica) # ============================================================ @http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def roles_permissions_get(self, **kw): try: return json_response({ 'status': 'success', 'matrix': get_menu_roles(request.env), 'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('admin') def roles_permissions_put(self, **kw): try: data = _parse_json_body() matrix = data.get('matrix') if not isinstance(matrix, dict): return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400) clean = {} for href, roles in matrix.items(): if not isinstance(href, str): return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400) if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles): return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400) clean[href] = list(dict.fromkeys(roles)) if 'admin' not in clean.get('/configuracion', []): return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400) ICP = request.env['ir.config_parameter'].sudo() anterior = ICP.get_param(MENU_ROLES_KEY, '') or '' ICP.set_param(MENU_ROLES_KEY, json.dumps(clean)) self._auditar('roles', 'menu_roles', 'permisos', (anterior[:2000] or '(por defecto)'), json.dumps(clean)[:2000], entity_label='Matriz de menús por rol', notes=f'{len(clean)} rutas') return json_response({'status': 'success', 'matrix': clean}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Dashboard # ============================================================ @http.route('/skeen/frontend/v1/dashboard', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def dashboard_stats(self, **kw): """Estadísticas para el dashboard""" try: today = date.today() first_day = today.replace(day=1) Citas = request.env['skeen.cita'].sudo() Pacientes = request.env['res.partner'].sudo() Monederos = request.env['skeen.monedero'].sudo() Ventas = request.env['skeen.venta'].sudo() citas_hoy = Citas.search_count([('date', '=', today)]) citas_mes = Citas.search_count([('date', '>=', first_day), ('date', '<=', today)]) pacientes_nuevos_mes = Pacientes.search_count([ ('is_patient', '=', True), ('create_date', '>=', first_day.strftime('%Y-%m-%d 00:00:00')), ]) total_pacientes = Pacientes.search_count([('is_patient', '=', True)]) ingresos_mes = sum(v.total for v in Ventas.search([ ('date', '>=', first_day), ('date', '<=', today), ('state', 'in', ('paid', 'partial')), ])) pendientes_pago = Ventas.search_count([('state', 'in', ('confirmed', 'partial')), ('amount_due', '>', 0)]) puntos_totales = sum(m.points for m in Monederos.search([])) ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))]) # Comparativas contra el período anterior equivalente (deltas): # - Ingresos Mes: mes anterior recortado al mismo día de corte (MTD) # - Ventas Hoy: mismo conteo del día de ayer prev_month_last = first_day - timedelta(days=1) prev_month_first = prev_month_last.replace(day=1) prev_month_end = prev_month_first.replace(day=min(today.day, prev_month_last.day)) prev_revenue_month = sum(v.total for v in Ventas.search([ ('date', '>=', prev_month_first), ('date', '<=', prev_month_end), ('state', 'in', ('paid', 'partial')), ])) prev_sales_today = Ventas.search_count([ ('date', '=', today - timedelta(days=1)), ('state', 'in', ('paid', 'partial')), ]) return json_response({ 'status': 'success', 'stats': { 'appointments_today': citas_hoy, 'appointments_month': citas_mes, 'new_patients_month': pacientes_nuevos_mes, 'total_patients': total_pacientes, 'revenue_month': ingresos_mes, 'pending_payments': pendientes_pago, 'total_wallet_points': puntos_totales, 'sales_today': ventas_hoy, 'prev_revenue_month': prev_revenue_month, 'prev_sales_today': prev_sales_today, } }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/weekly_chart', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def weekly_chart(self, **kw): """Datos para gráfico semanal""" try: today = date.today() start = today - timedelta(days=today.weekday()) data = [] Citas = request.env['skeen.cita'].sudo() Ventas = request.env['skeen.venta'].sudo() for i in range(7): day = start + timedelta(days=i) day_citas = Citas.search([('date', '=', day)]) day_ventas = Ventas.search([('date', '=', day), ('state', 'in', ('paid', 'partial'))]) data.append({ 'name': day.strftime('%a'), 'appointments': len(day_citas), 'revenue': sum(v.total for v in day_ventas), 'patients': len(set(c.partner_id.id for c in day_citas)), }) return json_response({'status': 'success', 'data': data}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Citas # ============================================================ @http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_appointments(self, **kw): """Listar citas (opcionalmente filtradas por fecha, estado, paciente) Rol 'medico': solo ve las citas asignadas a su hr.employee vinculado (filtro del lado del servidor, ignora cualquier doctor_id recibido). """ try: user = request.frontend_user if user and user.role == 'medico': emp = self._linked_employee(user) if not emp: # Sin empleado vinculado: agenda vacía (el frontend muestra aviso) return json_response({ 'status': 'success', 'appointments': [], 'bloqueos': [], 'total': 0, 'page': 1, 'page_size': int(kw.get('page_size', 50) or 50), 'total_pages': 0, 'medico_sin_employee': True, }) domain = [('doctor_id', '=', emp.id)] else: domain = [] if kw.get('date'): domain.append(('date', '=', kw.get('date'))) if kw.get('date_from'): domain.append(('date', '>=', kw.get('date_from'))) if kw.get('date_to'): domain.append(('date', '<=', kw.get('date_to'))) if kw.get('state'): domain.append(('state', '=', kw.get('state'))) if kw.get('patient_id'): domain.append(('partner_id', '=', int(kw.get('patient_id')))) if kw.get('doctor_id') and not (user and user.role == 'medico'): domain.append(('doctor_id', '=', int(kw.get('doctor_id')))) if kw.get('search'): s = kw.get('search') domain += ['|', '|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s), ('servicio_id.name', 'ilike', s)] # pending_confirm=1: citas activas SIN 1ra confirmación telefónica if kw.get('pending_confirm') in ('1', 'true', 'True'): domain.append(('phone_confirmed', '=', False)) domain.append(('state', 'not in', ('cancelled', 'no_show', '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), 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def create_appointment(self, **kw): """Crear una o más citas (soporta paquetes de sesiones)""" try: data = _parse_json_body() # Acepta ambos estilos de clave (el frontend usa patient_id/service_id) partner_id = data.get('partner_id', data.get('patient_id')) servicio_id = data.get('servicio_id', data.get('service_id')) if not partner_id or not servicio_id: return json_response({'status': 'error', 'message': 'Paciente y servicio son obligatorios'}, 400) servicio = request.env['skeen.servicio'].sudo().browse(int(servicio_id)) sessions = int(data.get('sessions', 1) or 1) if sessions < 1: sessions = 1 if servicio.exists() and servicio.package_sessions > 0: # Si el servicio tiene paquete y no se especificó sessions, default al paquete sessions = sessions if data.get('sessions') else servicio.package_sessions base_vals = { 'partner_id': partner_id, 'servicio_id': servicio_id, 'doctor_id': data.get('doctor_id'), 'branch': data.get('branch', 'rosarito'), 'medium': data.get('medium', 'onsite'), 'notes': data.get('notes', ''), 'state': data.get('state', 'pending'), } first_date = data.get('date') first_time = data.get('time') if first_time is not None and not self._valid_agenda_time(first_time): return json_response( {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) created = [] # Para paquetes, generamos citas separadas con fechas consecutivas current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today() for i in range(sessions): vals = dict(base_vals) vals['date'] = current_date.strftime('%Y-%m-%d') vals['time'] = first_time if i == 0 else 9.0 # Las siguientes se dejan a las 9:00 AM para reprogramar cita = request.env['skeen.cita'].sudo().create(vals) created.append(cita) current_date += timedelta(days=7 if sessions > 1 else 0) return json_response({ 'status': 'success', 'appointment': self._appointment_to_dict(created[0]), 'appointments': [self._appointment_to_dict(c) for c in created], 'sessions': sessions, }, 201) except ValidationError as e: # Conflicto de agenda (solape o bloqueo): responder 409 con la cita # en conflicto cuando se pueda identificar. request.env.cr.rollback() resp = {'status': 'error', 'message': str(e)} resp.update(self._agenda_conflict_payload({ 'date': data.get('date'), 'time': data.get('time'), 'doctor_id': data.get('doctor_id'), 'servicio_id': servicio_id, 'state': data.get('state', 'pending'), })) return json_response(resp, 409) except Exception as e: # Rollback explícito: evita persistir citas cuyas validaciones fallaron request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def create_quick_appointment(self, **kw): """Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto). Busca el paciente por teléfono y lo reutiliza si existe; si no, lo crea con datos mínimos. El servicio usa uno por defecto (primera 'consulta' activa, o el primer servicio activo) salvo que se indique servicio_id. """ try: data = _parse_json_body() name = (data.get('name') or '').strip() phone = (data.get('phone') or '').strip() if not name or not phone: return json_response({'status': 'error', 'message': 'Nombre y teléfono son obligatorios'}, 400) apt_date = data.get('date') or date.today().strftime('%Y-%m-%d') apt_time = data.get('time') if apt_time is None or not self._valid_agenda_time(apt_time): return json_response( {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) Partner = request.env['res.partner'].sudo() digits = re.sub(r'\D', '', phone) patient = None if len(digits) >= 7: # Comparación sobre dígitos normalizados (ignora espacios, +52, etc.): # coinciden si los últimos N dígitos (N = longitud del más corto) son iguales match_id = None candidates = Partner.search_read( [('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)], ['phone', 'mobile'], limit=10000) for c in candidates: for f in ('phone', 'mobile'): c_digits = re.sub(r'\D', '', c.get(f) or '') n = min(len(c_digits), len(digits)) if n >= 7 and c_digits[-n:] == digits[-n:]: match_id = c['id'] break if match_id: break if match_id: patient = Partner.browse(match_id) if not patient: patient = Partner.search([ ('is_patient', '=', True), ('phone', '=', phone), ], limit=1) patient_created = False if not patient: patient = Partner.create({ 'name': name, 'phone': phone, 'is_patient': True, 'source': data.get('source', 'walkin'), }) patient_created = True Servicio = request.env['skeen.servicio'].sudo() servicio = Servicio.browse(int(data.get('servicio_id') or 0)) if not servicio.exists(): servicio = Servicio.search([('category', '=', 'consulta'), ('active', '=', True)], order='sequence, id', limit=1) if not servicio: servicio = Servicio.search([('active', '=', True)], order='sequence, id', limit=1) if not servicio: return json_response({'status': 'error', 'message': 'No hay servicios activos para agendar'}, 400) cita = request.env['skeen.cita'].sudo().create({ 'partner_id': patient.id, 'servicio_id': servicio.id, 'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False, 'date': apt_date, 'time': float(apt_time), 'branch': data.get('branch', 'rosarito'), 'medium': data.get('medium', 'onsite'), 'notes': data.get('notes', ''), 'state': data.get('state', 'pending'), }) 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') def update_appointment(self, appointment_id, **kw): try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) data = _parse_json_body() allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state'] vals = {k: data[k] for k in allowed if k in data} if 'time' in vals and not self._valid_agenda_time(vals['time']): return json_response( {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) try: cita.write(vals) except ValidationError as e: # Conflicto de agenda: valores efectivos = cita actual + cambios request.env.cr.rollback() effective = { 'date': vals.get('date') or (cita.date.strftime('%Y-%m-%d') if cita.date else None), 'time': vals.get('time', cita.time), 'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False), 'servicio_id': vals.get('servicio_id', cita.servicio_id.id), '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') 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): try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) data = _parse_json_body() action = data.get('action') if action == 'confirm': cita.action_confirm() elif action == 'arrive': cita.action_arrive() elif action == 'start': cita.action_start() elif action == 'done': cita.action_done() elif action == 'cancel': cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')}) elif action == 'no_show': cita.action_no_show() else: return json_response({'status': 'error', 'message': 'Acción no válida'}, 400) return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments//phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def appointment_phone_confirm(self, appointment_id, **kw): """Toggle de la 1ra confirmación (telefónica) de una cita. Body: {"confirmed": true|false}. Si no se envía, invierte el valor actual. Registra quién (login del usuario frontend) y cuándo confirmó. Permiso: recepción+ (mismo nivel que check-in/no-show; el rol medico tiene el mismo rango y también puede confirmar). """ try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) data = _parse_json_body() confirmed = bool(data.get('confirmed', not cita.phone_confirmed)) if confirmed: cita.write({ 'phone_confirmed': True, 'phone_confirmed_at': fields.Datetime.now(), 'phone_confirmed_by': request.frontend_user.login, }) else: cita.write({ 'phone_confirmed': False, 'phone_confirmed_at': False, 'phone_confirmed_by': False, }) return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments//package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def appointment_package_finished(self, appointment_id, **kw): try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) if not cita.exists(): return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404) data = _parse_json_body() # finished=false revierte la marca (reversible) finished = data.get('finished', True) if finished: cita.action_package_finished() else: cita.write({'package_finished': False, 'package_finished_date': False}) return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def bulk_phone_confirm(self, **kw): """Confirmación telefónica en lote. Body: {"ids": [..], "confirmed": true|false}. Valida que todas las citas existan y no estén canceladas / no_show / done; confirma el resto y devuelve conteos. Permiso: recepción+ (igual que phone_confirm). """ try: data = _parse_json_body() raw_ids = data.get('ids') or [] confirmed = bool(data.get('confirmed', True)) if not isinstance(raw_ids, list) or not raw_ids: return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400) try: ids = [int(i) for i in raw_ids] except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400) Citas = request.env['skeen.cita'].sudo() citas = Citas.browse(ids).exists() found = set(citas.ids) not_found = [i for i in dict.fromkeys(ids) if i not in found] elegibles = citas.filtered(lambda c: c.state not in ('cancelled', 'no_show', 'done')) skipped_invalid_state = [c.id for c in citas if c.state in ('cancelled', 'no_show', '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 (payload_extra, http_status): {'conflicting_appointment': ...} cuando hay solapamiento con otra cita, o {} si no se pudo identificar. Replica la lógica de _check_availability de skeen.cita. """ 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'): return {} servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0)) dur = (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')), ('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): 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 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) return json_response({'status': 'success', 'slots': slots}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Bloqueos de agenda por médico # ============================================================ def _bloqueos_rango(self, citas_domain): """Bloqueos del rango de fechas consultado en list_appointments""" date_from = date_to = None for cond in citas_domain: if isinstance(cond, (list, tuple)) and cond[0] == 'date': if cond[1] == '=': date_from = date_to = cond[2] elif cond[1] == '>=': date_from = cond[2] elif cond[1] == '<=': date_to = cond[2] if not date_from and not date_to: date_from = date_to = date.today().strftime('%Y-%m-%d') domain = [] if date_from: domain.append(('date', '>=', date_from)) if date_to: domain.append(('date', '<=', date_to)) bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, limit=500) return [self._bloqueo_to_dict(b) for b in bloqueos] def _bloqueo_to_dict(self, b): return { 'id': b.id, 'doctor_id': b.doctor_id.id, 'doctor': b.doctor_id.name, 'date': b.date.strftime('%Y-%m-%d') if b.date else None, 'all_day': b.all_day, 'time_from': b.time_from, 'time_to': b.time_to, 'motivo': b.motivo or '', } @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_bloqueos(self, **kw): try: domain = [] if kw.get('date_from'): domain.append(('date', '>=', kw['date_from'])) if kw.get('date_to'): domain.append(('date', '<=', kw['date_to'])) if kw.get('doctor_id'): domain.append(('doctor_id', '=', int(kw.get('doctor_id')))) bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc', limit=500) return json_response({ 'status': 'success', 'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def create_bloqueo(self, **kw): try: data = _parse_json_body() if not data.get('doctor_id') or not data.get('date'): return json_response({'status': 'error', 'message': 'Médico y fecha son obligatorios'}, 400) all_day = bool(data.get('all_day')) time_from = float(data.get('time_from', 9.0) or 9.0) time_to = float(data.get('time_to', 10.0) or 10.0) if not all_day and time_to <= time_from: return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400) doctor = request.env['hr.employee'].sudo().browse(int(data['doctor_id'])) if not doctor.exists(): return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404) bloqueo = request.env['skeen.cita.bloqueo'].sudo().create({ 'doctor_id': doctor.id, 'date': data['date'], 'all_day': all_day, 'time_from': time_from, 'time_to': time_to, 'motivo': data.get('motivo', ''), }) return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/bloqueos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') def delete_bloqueo(self, bloqueo_id, **kw): try: bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id) if not bloqueo.exists(): return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404) bloqueo.unlink() return json_response({'status': 'success', 'message': 'Bloqueo eliminado'}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _appointment_to_dict(self, cita): return { 'id': cita.id, 'reference': cita.name, 'patient_id': cita.partner_id.id, 'patient': cita.partner_id.name, 'phone': cita.partner_id.phone or '', 'service_id': cita.servicio_id.id, 'service': cita.servicio_id.name, 'service_category': cita.servicio_id.category, 'date': cita.date.strftime('%Y-%m-%d') if cita.date else None, 'time': cita.time_str, 'state': cita.state, 'payment_state': cita.payment_state, 'price': cita.servicio_price, 'amount_paid': cita.amount_paid, 'duration': cita.servicio_duration, 'doctor_id': cita.doctor_id.id if cita.doctor_id else None, 'doctor': cita.doctor_id.name if cita.doctor_id else None, 'branch': cita.branch, 'medium': cita.medium, 'notes': cita.notes or '', 'is_first_visit': False, 'package_finished': cita.package_finished, 'package_finished_date': cita.package_finished_date.strftime('%Y-%m-%d %H:%M') if cita.package_finished_date else None, # 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') 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') 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') def cancel_visita(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) if not visita.exists(): return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404) visita.action_cancel() return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def create_consulta(self, **kw): """Consulta médica directa: visita completada sin cita origen""" try: data = _parse_json_body() partner_id = int(data.get('partner_id', 0) or 0) partner = request.env['res.partner'].sudo().browse(partner_id) if not partner.exists(): return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404) ahora = fields.Datetime.now() visita = request.env['skeen.visita'].sudo().create({ 'partner_id': partner.id, 'origen': 'consulta', 'state': 'completada', 'date_start': ahora, 'date_end': ahora, 'motivo': data.get('motivo', ''), 'diagnostico': data.get('diagnostico', ''), 'tratamiento': data.get('tratamiento', ''), 'notas': data.get('notas', ''), 'doctor_id': data.get('doctor_id') or False, }) return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/visitas//insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def add_visita_insumo(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) if not visita.exists(): return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404) if visita.state != 'en_curso': return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400) data = _parse_json_body() qty = float(data.get('qty', 0) or 0) if qty <= 0: return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400) item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0)) if not item.exists(): return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404) request.env['skeen.visita.insumo'].sudo().create({ 'visita_id': visita.id, 'item_id': item.id, 'qty': qty, 'notes': data.get('notes', ''), }) return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/visitas//insumos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') def delete_visita_insumo(self, visita_id, line_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) if not visita.exists(): return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404) if visita.state != 'en_curso': return json_response({'status': 'error', 'message': 'Solo se pueden editar insumos de visitas en curso'}, 400) linea = request.env['skeen.visita.insumo'].sudo().browse(line_id) if not linea.exists() or linea.visita_id.id != visita.id: return json_response({'status': 'error', 'message': 'Insumo no encontrado'}, 404) linea.unlink() return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/visitas//adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def add_visita_adjunto(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) if not visita.exists(): return json_response({'status': 'error', 'message': 'Visita no encontrada'}, 404) if visita.state == 'cancelada': return json_response({'status': 'error', 'message': 'No se pueden adjuntar archivos a una visita cancelada'}, 400) data = _parse_json_body() kind = data.get('kind') if kind not in ('antes', 'despues', 'documento'): return json_response({'status': 'error', 'message': 'Tipo de adjunto no válido'}, 400) file_b64 = data.get('file_b64') or '' try: raw = base64.b64decode(file_b64) except Exception: return json_response({'status': 'error', 'message': 'Archivo base64 inválido'}, 400) if not raw: return json_response({'status': 'error', 'message': 'Archivo vacío'}, 400) if len(raw) > 10 * 1024 * 1024: return json_response({'status': 'error', 'message': 'El archivo excede 10 MB'}, 400) request.env['skeen.visita.adjunto'].sudo().create({ 'visita_id': visita.id, 'kind': kind, 'name': data.get('name') or 'archivo', 'file': file_b64, 'mimetype': data.get('mimetype') or '', 'notes': data.get('notes', ''), }) return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/visitas/adjuntos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') def delete_visita_adjunto(self, adj_id, **kw): try: adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id) if not adj.exists(): return json_response({'status': 'error', 'message': 'Adjunto no encontrado'}, 404) visita = adj.visita_id adj.unlink() return json_response({'status': 'success', 'visita': self._visita_to_dict(visita)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/visitas/adjuntos//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'): search = kw.get('search') domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)] if kw.get('incomplete') == '1': domain += [('expediente_completion', '<', 100)] if kw.get('vip') == '1': domain.append(('is_vip', '=', True)) if kw.get('doctor_id'): domain.append(('primary_doctor_id', '=', int(kw.get('doctor_id')))) if kw.get('recent') == '1': seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d') domain.append(('last_visit', '>=', seis_meses)) 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', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def create_patient(self, **kw): try: data = _parse_json_body() vals = { 'name': data.get('name'), 'phone': data.get('phone'), 'email': data.get('email', ''), 'is_patient': True, 'birth_date': data.get('birth_date'), 'gender': data.get('gender'), 'blood_type': data.get('blood_type'), 'allergies': data.get('allergies', ''), 'medical_history': data.get('medical_history', ''), 'current_medication': data.get('current_medication', ''), 'source': data.get('source', 'whatsapp'), 'primary_doctor_id': data.get('primary_doctor_id'), 'is_vip': data.get('is_vip', False), # Datos personales extendidos 'birthplace': data.get('birthplace', ''), 'occupation': data.get('occupation', ''), 'marital_status': data.get('marital_status', ''), 'emergency_contact': data.get('emergency_contact', ''), 'emergency_phone': data.get('emergency_phone', ''), 'home_phone': data.get('home_phone', ''), 'mobile': data.get('mobile', ''), 'address_notes': data.get('address_notes', ''), 'referred_by': data.get('referred_by', ''), 'patient_comments': data.get('patient_comments', ''), 'internal_notes': data.get('internal_notes', ''), # Historia clínica 'is_pregnant': data.get('is_pregnant', False), 'is_breastfeeding': data.get('is_breastfeeding', False), 'uses_contraceptives': data.get('uses_contraceptives', False), 'children_count': data.get('children_count', 0), 'kidney_problems': data.get('kidney_problems', False), 'back_pain': data.get('back_pain', False), 'heart_disease': data.get('heart_disease', False), 'respiratory_problems': data.get('respiratory_problems', False), 'blood_pressure': data.get('blood_pressure', False), 'diabetes': data.get('diabetes', False), 'thyroid': data.get('thyroid', False), 'colitis': data.get('colitis', False), 'constipation': data.get('constipation', False), 'liver_problems': data.get('liver_problems', False), 'surgeries': data.get('surgeries', False), 'surgeries_notes': data.get('surgeries_notes', ''), 'varicose_veins': data.get('varicose_veins', False), 'migraine': data.get('migraine', False), 'faints_with_needles': data.get('faints_with_needles', False), 'medical_notes': data.get('medical_notes', ''), 'whatsapp': data.get('whatsapp', ''), } if data.get('photo'): vals['image_1920'] = data['photo'] patient = request.env['res.partner'].sudo().create(vals) return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients/', 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') def update_patient(self, patient_id, **kw): try: patient = request.env['res.partner'].sudo().browse(patient_id) if not patient.exists(): return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404) data = _parse_json_body() allowed = ['name', 'phone', 'email', 'birth_date', 'gender', 'blood_type', 'allergies', 'medical_history', 'current_medication', 'source', 'primary_doctor_id', 'is_vip', # Datos personales extendidos 'birthplace', 'occupation', 'marital_status', 'emergency_contact', 'emergency_phone', 'home_phone', 'mobile', 'address_notes', 'referred_by', 'patient_comments', 'internal_notes', # Historia clínica 'is_pregnant', 'is_breastfeeding', 'uses_contraceptives', 'children_count', 'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems', 'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation', 'liver_problems', 'surgeries', 'surgeries_notes', 'varicose_veins', 'migraine', 'faints_with_needles', 'medical_notes', 'whatsapp'] vals = {k: data[k] for k in allowed if k in data} if data.get('photo'): vals['image_1920'] = data['photo'] patient.write(vals) return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/patients//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. El sistema no guarda un contador de sesiones: el paquete se materializa como las citas creadas para un servicio con `package_sessions > 0` (create_appointment genera una cita por sesión). Por eso: total = citas no canceladas del paciente con ese servicio usadas = citas ya atendidas (llegó / en curso / completada) restantes = total - usadas """ try: Citas = request.env['skeen.cita'].sudo() citas = Citas.search([ ('partner_id', '=', patient_id), ('state', 'not in', ('cancelled', 'no_show')), ('servicio_id.package_sessions', '>', 0), ]) grupos = {} for c in citas: g = grupos.setdefault(c.servicio_id.id, { 'service_id': c.servicio_id.id, 'name': c.servicio_id.name, 'package_sessions': c.servicio_id.package_sessions, 'total': 0, 'used': 0, 'next_date': None, }) g['total'] += 1 if c.state in ('arrived', 'in_progress', 'done'): g['used'] += 1 elif c.date and (g['next_date'] is None or c.date.strftime('%Y-%m-%d') < g['next_date']): g['next_date'] = c.date.strftime('%Y-%m-%d') rows = [] for g in grupos.values(): g['remaining'] = max(0, g['total'] - g['used']) rows.append(g) rows.sort(key=lambda r: (-r['remaining'], r['name'])) return json_response({'status': 'success', 'packages': rows}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Adeudos: cobro con recibo y plan de pagos # ============================================================ def _adeudo_ventas(self, patient_id): """Ventas con saldo pendiente del paciente, más antiguas primero.""" return request.env['skeen.venta'].sudo().search([ ('partner_id', '=', patient_id), ('state', '!=', 'cancelled'), ('amount_due', '>', 0), ], order='date asc, id asc') @http.route('/skeen/frontend/v1/patients//pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') 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 = [] for venta in ventas: if restante <= 0: break por_aplicar = round(min(restante, venta.amount_due), 2) if por_aplicar <= 0: continue 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, } @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), '|', '|', ('name', 'ilike', q), ('phone', 'ilike', q), ('email', 'ilike', q)], limit=5, order='name asc') Citas = request.env['skeen.cita'].sudo() citas = Citas.search( [('date', '>=', date.today().strftime('%Y-%m-%d')), ('state', 'not in', ('cancelled', 'no_show')), '|', ('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)], limit=5, order='date asc, time asc') Servicios = request.env['skeen.servicio'].sudo() servicios = Servicios.search( [('active', '=', True), ('price', '>', 0), ('name', 'ilike', q)], limit=5, order='name asc') return json_response({ 'status': 'success', 'patients': [{ 'id': p.id, 'name': p.name, 'phone': p.phone or '', } for p in patients], 'appointments': [{ 'id': c.id, 'patient': c.partner_id.name, 'service': c.servicio_id.name, 'date': c.date.strftime('%Y-%m-%d') if c.date else None, 'time': c.time_str, } for c in citas], 'services': [{ 'id': s.id, 'name': s.name, 'price': s.price, } for s in servicios], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _patient_to_dict(self, p): return { 'id': p.id, 'patient_id': p.patient_id, 'name': p.name, 'phone': p.phone or '', 'email': p.email or '', 'birth_date': p.birth_date.strftime('%Y-%m-%d') if p.birth_date else None, 'age': p.age, 'gender': p.gender or '', 'blood_type': p.blood_type or '', 'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None, 'total_visits': p.total_visits, 'wallet_points': p.wallet_points, 'total_spent': p.total_spent, 'source': p.source or '', 'is_vip': p.is_vip, 'primary_doctor_id': p.primary_doctor_id.id if p.primary_doctor_id else None, 'primary_doctor': p.primary_doctor_id.name if p.primary_doctor_id else None, 'whatsapp': p.whatsapp or '', 'has_photo': bool(p.image_1920), 'photo_url': f'/skeen/frontend/v1/patients/{p.id}/photo', # Datos personales extendidos 'birthplace': p.birthplace or '', 'occupation': p.occupation or '', 'marital_status': p.marital_status or '', 'emergency_contact': p.emergency_contact or '', 'emergency_phone': p.emergency_phone or '', 'home_phone': p.home_phone or '', 'mobile': p.mobile or '', 'address_notes': p.address_notes or '', 'referred_by': p.referred_by or '', 'patient_comments': p.patient_comments or '', 'internal_notes': p.internal_notes or '', # Historia clínica 'is_pregnant': p.is_pregnant, 'is_breastfeeding': p.is_breastfeeding, 'uses_contraceptives': p.uses_contraceptives, 'children_count': p.children_count, 'kidney_problems': p.kidney_problems, 'back_pain': p.back_pain, 'heart_disease': p.heart_disease, 'respiratory_problems': p.respiratory_problems, 'blood_pressure': p.blood_pressure, 'diabetes': p.diabetes, 'thyroid': p.thyroid, 'colitis': p.colitis, 'constipation': p.constipation, 'liver_problems': p.liver_problems, 'surgeries': p.surgeries, 'surgeries_notes': p.surgeries_notes or '', 'varicose_veins': p.varicose_veins, 'migraine': p.migraine, 'faints_with_needles': p.faints_with_needles, 'medical_notes': p.medical_notes or '', 'allergies': p.allergies or '', # Completitud de expediente 'expediente_completion': p.expediente_completion, 'expediente_missing': p.expediente_missing.split('|') if p.expediente_missing else [], } # ============================================================ # Servicios # ============================================================ @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_services(self, **kw): try: domain = [('active', '=', True), ('price', '>', 0), ('duration_min', '>', 0)] if kw.get('search'): search = kw.get('search') domain += ['|', ('name', 'ilike', search), ('code', 'ilike', search)] if kw.get('category'): domain += [('category', '=', kw.get('category'))] page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 200) or 200), 1), 200) Servicio = request.env['skeen.servicio'].sudo() # Orden: manual (sequence, default) o por más vendidos de los # últimos 90 días. "best_sellers" ordena en memoria porque el # ranking sale de las líneas de venta, no del catálogo. sort = kw.get('sort') or 'manual' ventas_90d = {} if sort == 'best_sellers': desde = (date.today() - timedelta(days=90)).strftime('%Y-%m-%d') lineas = request.env['skeen.venta.line'].sudo().read_group( [('service_id', '!=', False), ('venta_id.state', '!=', 'cancelled'), ('venta_id.date', '>=', desde)], ['service_id', 'quantity:sum'], ['service_id']) ventas_90d = {g['service_id'][0]: (g.get('quantity') or 0.0) for g in lineas} if sort == 'best_sellers': todos = Servicio.search(domain, order='sequence asc, name asc') todos = todos.sorted( key=lambda s: (-ventas_90d.get(s.id, 0.0), s.sequence or 0, s.name or '')) total = len(todos) services = todos[(page - 1) * page_size: page * page_size] else: total = Servicio.search_count(domain) services = Servicio.search(domain, order='sequence asc, name asc', limit=page_size, offset=(page - 1) * page_size) return json_response({ 'status': 'success', 'services': [self._service_to_dict(s, ventas_90d) for s in services], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'sort': sort, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False) @require_role('admin') def create_service(self, **kw): try: data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) Servicio = request.env['skeen.servicio'].sudo() existente = Servicio.search([('name', '=ilike', name)], limit=1) if existente: return json_response({ 'status': 'error', 'message': f'Ya existe un servicio con ese nombre (ID {existente.id}). Edítalo en vez de duplicarlo.', }, 409) code = (data.get('code') or '').strip() if code: dup_code = Servicio.search([('code', '=', code)], limit=1) if dup_code: return json_response({ 'status': 'error', 'message': f'El código {code} ya está en uso por "{dup_code.name}".', }, 409) vals = { 'name': name, 'code': code, 'category': data.get('category', 'tratamiento'), 'price': data.get('price', 0.0), 'duration_min': data.get('duration_min', 30), 'package_price': data.get('package_price', 0.0), 'package_sessions': data.get('package_sessions', 0), 'package_notes': data.get('package_notes', ''), 'description': data.get('description', ''), 'color': data.get('color', '#1abc9c'), 'service_group': data.get('service_group', ''), 'is_favorite': data.get('is_favorite', False), } service = Servicio.create(vals) return json_response({'status': 'success', 'service': self._service_to_dict(service)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') def reorder_services(self, **kw): """Reordena el catálogo y renumera las secuencias (10, 20, 30...). Los servicios de `ids` quedan, en ese mismo orden, ocupando la posición del primero de ellos (así funcionan las flechas subir/bajar: se envía la página ya reordenada). Con `to: 'front'` el bloque pasa al inicio del catálogo (botón "Mostrar primero en POS"). Con la numeración normalizada el orden manual es estable y el POS lo respeta al listar. """ try: data = _parse_json_body() raw = data.get('ids') or [] if not isinstance(raw, list) or not raw: return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400) try: ids = [int(i) for i in raw] except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400) Servicio = request.env['skeen.servicio'].sudo() todos = Servicio.search([], order='sequence asc, name asc') orden = [s.id for s in todos] mover_set = set(ids) mover = [i for i in ids if i in set(orden)] if not mover: return json_response({'status': 'error', 'message': 'Ningún servicio válido en la lista'}, 404) resto = [i for i in orden if i not in mover_set] if (data.get('to') or '').lower() == 'front': nuevo = mover + resto else: primera = orden.index(mover[0]) pos = len([i for i in orden[:primera] if i not in mover_set]) nuevo = resto[:pos] + mover + resto[pos:] for idx, sid in enumerate(nuevo): Servicio.browse(sid).write({'sequence': (idx + 1) * 10}) return json_response({'status': 'success', 'total': len(nuevo), 'ids': nuevo[:len(ids)]}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('admin') def update_service(self, service_id, **kw): try: service = request.env['skeen.servicio'].sudo().browse(service_id) if not service.exists(): return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404) data = _parse_json_body() allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence'] vals = {k: data[k] for k in allowed if k in data} Servicio = request.env['skeen.servicio'].sudo() if 'sequence' in vals: try: vals['sequence'] = int(vals['sequence']) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La secuencia debe ser numérica'}, 400) if vals.get('name'): dup = Servicio.search([('name', '=ilike', vals['name'].strip()), ('id', '!=', service_id)], limit=1) if dup: return json_response({'status': 'error', 'message': f'Ya existe otro servicio con ese nombre (ID {dup.id}).'}, 409) if vals.get('code'): dupc = Servicio.search([('code', '=', vals['code']), ('id', '!=', service_id)], limit=1) if dupc: return json_response({'status': 'error', 'message': f'El código {vals["code"]} ya está en uso por "{dupc.name}".'}, 409) service.write(vals) return json_response({'status': 'success', 'service': self._service_to_dict(service)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _service_to_dict(self, s, ventas_90d=None): return { 'id': s.id, 'code': s.code, 'name': s.name, 'category': s.category, 'price': s.price, 'package_price': s.package_price, 'package_sessions': s.package_sessions, 'package_notes': s.package_notes or '', 'duration_min': s.duration_min, 'description': s.description or '', 'color': s.color, 'service_group': s.service_group or '', 'is_favorite': s.is_favorite, 'sequence': s.sequence or 0, 'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None, 'genera_monedero': s.genera_monedero, } # ============================================================ # Médicos # ============================================================ @http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_doctors(self, **kw): """Médicos + estadísticas de ventas de los últimos 30 días. - ventas_30d: ventas distintas (state != cancelled) con >=1 línea recetada por el médico (prescribed_by_id), fecha >= hoy-30. - total_30d: suma del subtotal de las líneas recetadas (misma métrica que el reporte de comisiones). - total_30d_usd: total_30d / TC vigente (None si no hay TC). - card_30d: pagado con tarjeta física. Exacto para ventas nuevas vía venta.card_amount (se llena en pay_sale y POS checkout). Aproximación histórica para ventas anteriores: si la venta tiene cita_id, se suman los pagos completed con method='card' ligados a esa cita, topados al amount_paid de la venta (por eso es aproximada: el pago no liga a la venta, solo a la cita). Stripe/MercadoPago no cuentan como tarjeta. """ try: # Rol medico: la agenda necesita la lista de doctores, pero los KPIs # de dinero por médico son del módulo Médicos (admin/recepción). fu = current_user(request.env) hide_money = bool(fu and fu.role == 'medico') doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100) # Pacientes asignados por médico (primary_doctor_id) counts = {} for g in request.env['res.partner'].sudo().read_group( [('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)], [], ['primary_doctor_id']): counts[g['primary_doctor_id'][0]] = g['primary_doctor_id_count'] # ---- Ventas de los últimos 30 días por médico ---- since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d') # ---- Ventas del mes en curso (para la meta mensual) ---- mes_ini = date.today().replace(day=1).strftime('%Y-%m-%d') mes_total = {} for g in request.env['skeen.venta.line'].sudo().read_group( [('prescribed_by_id', '!=', False), ('venta_id.state', '!=', 'cancelled'), ('venta_id.date', '>=', mes_ini)], ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']): mes_total[g['prescribed_by_id'][0]] = round(g['subtotal'] or 0.0, 2) # Recomendaciones: pacientes captados en 30d asignados al médico # con "recomendado por" capturado (misma métrica visible del legacy) recomendaciones = {} for g in request.env['res.partner'].sudo().read_group( [('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids), ('referred_by', 'not in', [False, '']), ('create_date', '>=', since)], [], ['primary_doctor_id']): recomendaciones[g['primary_doctor_id'][0]] = g['primary_doctor_id_count'] stats = {} # doctor_id -> {'ventas': set, 'total': float} card_by_venta = {} ventas = request.env['skeen.venta'].sudo().search([ ('state', '!=', 'cancelled'), ('date', '>=', since), ]) if ventas: for r in request.env['skeen.venta.line'].sudo().search_read( [('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)], ['prescribed_by_id', 'venta_id', 'subtotal']): did = r['prescribed_by_id'][0] st = stats.setdefault(did, {'ventas': set(), 'total': 0.0}) st['ventas'].add(r['venta_id'][0]) st['total'] += r['subtotal'] or 0.0 # Tarjeta: exacto (card_amount) + aprox histórica por cita ventas_by_id = {v.id: v for v in ventas} for vid, v in ventas_by_id.items(): card_by_venta[vid] = v.card_amount or 0.0 citas = ventas.mapped('cita_id') if citas: pgroup = request.env['skeen.pago'].sudo().read_group( [('cita_id', 'in', citas.ids), ('payment_method', '=', 'card'), ('state', '=', 'completed')], ['cita_id', 'amount:sum'], ['cita_id']) card_by_cita = {g['cita_id'][0]: g['amount'] for g in pgroup} for v in ventas: if v.cita_id: aprox = min(card_by_cita.get(v.cita_id.id, 0.0), v.amount_paid or 0.0) card_by_venta[v.id] = max(v.card_amount or 0.0, aprox) # TC vigente (para el equivalente USD) tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1) rate = tc.rate if tc else None return json_response({ 'status': 'success', 'doctors': [{ 'id': d.id, 'name': d.name, 'job_title': d.job_title or '', 'work_phone': d.work_phone or '', 'work_email': d.work_email or '', 'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0), 'staff_type': d.staff_type or 'doctor', 'monthly_goal': 0.0 if hide_money else (d.monthly_goal or 0.0), 'month_total': 0.0 if hide_money else mes_total.get(d.id, 0.0), 'horario': [{ 'day_of_week': h.day_of_week, 'hour_from': h.hour_from, 'hour_to': h.hour_to, } for h in d.horario_ids], 'tiene_horario': bool(d.horario_ids), 'horario_resumen': d._horario_resumen(), 'patient_count': counts.get(d.id, 0), 'recomendaciones_30d': recomendaciones.get(d.id, 0), 'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())), 'total_30d': 0.0 if hide_money else round(stats.get(d.id, {}).get('total', 0.0), 2), 'total_30d_usd': None if hide_money else (round(stats.get(d.id, {}).get('total', 0.0) / rate, 2) if rate else None), 'card_30d': 0.0 if hide_money else round(sum(card_by_venta.get(vid, 0.0) for vid in stats.get(d.id, {}).get('ventas', set())), 2), } for d in doctors], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/doctors/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('admin') def update_doctor(self, doctor_id, **kw): try: doctor = request.env['hr.employee'].sudo().browse(doctor_id) if not doctor.exists(): return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404) data = _parse_json_body() vals = {} if 'commission_pct' in data: vals['commission_pct'] = float(data.get('commission_pct') or 0) if 'monthly_goal' in data: try: meta = float(data.get('monthly_goal') or 0) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'La meta mensual debe ser numérica'}, 400) if meta < 0: return json_response({'status': 'error', 'message': 'La meta mensual no puede ser negativa'}, 400) vals['monthly_goal'] = meta if 'staff_type' in data: if data['staff_type'] not in ('doctor', 'cosmetologa'): return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400) vals['staff_type'] = data['staff_type'] if vals: previos = {k: getattr(doctor, k) for k in vals} doctor.write(vals) for campo, nuevo in vals.items(): anterior = previos.get(campo) if str(anterior or '') == str(nuevo or ''): continue modulo = 'metas' if campo == 'monthly_goal' else 'comisiones' self._auditar(modulo, f'doctor:{doctor.id}', campo, anterior, nuevo, entity_label=doctor.name) return json_response({ 'status': 'success', 'doctor': { 'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '', 'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '', 'commission_pct': doctor.commission_pct or 0.0, 'monthly_goal': doctor.monthly_goal or 0.0, 'staff_type': doctor.staff_type or 'doctor', }, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/doctors//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 == '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 ('accrual', 'redemption', 'adjustment', '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') 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') def confirm_payment(self, payment_id, **kw): try: payment = request.env['skeen.pago'].sudo().browse(payment_id) if not payment.exists(): return json_response({'status': 'error', 'message': 'Pago no encontrado'}, 404) payment.action_confirm_payment() return json_response({'status': 'success', 'payment': self._payment_to_dict(payment)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _payment_to_dict(self, p): return { 'id': p.id, 'name': p.name, 'patient': p.partner_id.name, 'patient_id': p.partner_id.id, 'amount': p.amount, 'payment_method': p.payment_method, 'state': p.state, 'payment_url': p.payment_url or '', 'provider_reference': p.provider_reference or '', 'create_date': p.create_date.strftime('%Y-%m-%d %H:%M') if p.create_date else None, } # ============================================================ # Ventas # ============================================================ @http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_sales(self, **kw): try: domain = [] if kw.get('date'): domain.append(('date', '=', kw.get('date'))) if kw.get('date_from'): domain.append(('date', '>=', kw.get('date_from'))) if kw.get('date_to'): domain.append(('date', '<=', kw.get('date_to'))) if kw.get('state'): domain.append(('state', '=', kw.get('state'))) if kw.get('search'): s = kw.get('search') domain += ['|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s)] page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) Ventas = request.env['skeen.venta'].sudo() total = Ventas.search_count(domain) offset = (page - 1) * page_size sales = Ventas.search(domain, order='create_date desc', limit=page_size, offset=offset) return json_response({ 'status': 'success', 'sales': [self._sale_to_dict(v) for v in sales], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def create_sale(self, **kw): try: data = _parse_json_body() lines = data.get('lines', []) vals = { 'partner_id': data.get('partner_id'), 'cita_id': data.get('cita_id'), 'date': data.get('date', fields.Date.today()), 'discount': data.get('discount', 0.0), 'tax': data.get('tax', 0.0), 'notes': data.get('notes', ''), 'line_ids': [(0, 0, { 'service_id': line.get('service_id'), 'description': line.get('description', ''), 'quantity': line.get('quantity', 1.0), 'price_unit': line.get('price_unit', 0.0), 'is_prescribed': bool(line.get('is_prescribed', False)), 'prescribed_by_id': line.get('prescribed_by_id') or False, }) for line in lines], } sale = request.env['skeen.venta'].sudo().create(vals) if data.get('state') == 'confirmed': sale.action_confirm() return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales/', 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//pay', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def pay_sale(self, sale_id, **kw): try: sale = request.env['skeen.venta'].sudo().browse(sale_id) if not sale.exists(): return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404) data = _parse_json_body() method = data.get('payment_method', 'cash') amount = data.get('amount', sale.amount_due) # Crear pago asociado payment = request.env['skeen.pago'].sudo().create({ 'partner_id': sale.partner_id.id, 'cita_id': sale.cita_id.id if sale.cita_id else False, 'amount': amount, 'payment_method': method, 'state': 'completed', 'payment_date': fields.Datetime.now(), }) # 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí vals = {'amount_paid': sale.amount_paid + amount} if method == 'card': vals['card_amount'] = sale.card_amount + amount sale.write(vals) if sale.amount_paid >= sale.total: sale.action_mark_paid() else: sale.write({'state': 'partial'}) return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'payment': self._payment_to_dict(payment)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales//refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def refund_sale(self, sale_id, **kw): try: sale = request.env['skeen.venta'].sudo().browse(sale_id) if not sale.exists(): return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404) data = _parse_json_body() amount = data.get('amount') # None o <=0 => devolución total de lo pagado reason = data.get('reason', '') result = sale.action_refund(amount=amount, reason=reason) return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales//cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def cancel_sale(self, sale_id, **kw): """Cancelación de venta con nota de crédito (motivo obligatorio). Permiso: admin y gerencia (el rango de 'gerencia' no alcanza a require_role('admin'), por eso el chequeo explícito). """ try: if request.frontend_user.role not in ('admin', 'gerencia'): return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) sale = request.env['skeen.venta'].sudo().browse(sale_id) if not sale.exists(): return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404) data = _parse_json_body() reason = (data.get('reason') or '').strip() if not reason: return json_response({'status': 'error', 'message': 'El motivo de cancelación es obligatorio'}, 400) result = sale.action_cancel_with_reason( reason=reason, cancelled_by=request.frontend_user.login, restore_stock=bool(data.get('restore_stock', True)), ) return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result}) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales/last', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def last_sale(self, **kw): """Última venta del paciente, con precios ACTUALES del catálogo. Pensado para "Repetir última venta" en el POS: devuelve las líneas con la cantidad vendida la última vez y el precio vigente hoy, marcando precio cambiado / artículo faltante o inactivo. """ try: patient_id = int(kw.get('patient_id', 0) or 0) if not patient_id: return json_response({'status': 'error', 'message': 'Falta patient_id'}, 400) Ventas = request.env['skeen.venta'].sudo() last = Ventas.search( [('partner_id', '=', patient_id), ('state', '!=', 'cancelled')], order='create_date desc, id desc', limit=1) if not last: return json_response({'status': 'success', 'sale': None, 'lines': [], 'warnings': []}) lines = [] warnings = [] for line in last.line_ids: row = { 'service_id': line.service_id.id if line.service_id else None, 'item_id': line.item_id.id if line.item_id else None, 'name': line.service_id.name if line.service_id else (line.item_id.name if line.item_id else line.description), 'description': line.description or '', 'kind': 'service' if line.service_id else 'item', 'quantity': line.quantity, 'price_last': line.price_unit, 'price_current': None, 'price_changed': False, 'missing': False, 'stock': None, 'unit': None, 'active': True, } if line.service_id: svc = line.service_id row.update({ 'price_current': svc.price, 'price_changed': round(svc.price, 2) != round(line.price_unit, 2), 'active': svc.active, 'missing': not svc.active, }) if not svc.active: warnings.append(f'{svc.name}: el servicio ya no está activo') elif row['price_changed']: warnings.append(f'{svc.name}: el precio cambió de ${line.price_unit:g} a ${svc.price:g}') else: item = line.item_id row.update({ 'price_current': item.price, 'price_changed': round((item.price or 0), 2) != round(line.price_unit, 2), 'stock': item.qty, 'unit': item.unit, 'active': item.active, 'missing': not item.active, }) if not item.active: warnings.append(f'{item.name}: el artículo ya no está activo') elif row['price_changed']: warnings.append(f'{item.name}: el precio cambió de ${line.price_unit:g} a ${(item.price or 0):g}') lines.append(row) return json_response({ 'status': 'success', 'sale': self._sale_to_dict(last), 'lines': lines, 'warnings': warnings, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _sale_to_dict(self, v): return { 'id': v.id, 'name': v.name, 'patient_id': v.partner_id.id, 'patient': v.partner_id.name, 'date': v.date.strftime('%Y-%m-%d') if v.date else None, 'subtotal': v.subtotal, 'discount': v.discount, 'tax': v.tax, 'total': v.total, 'amount_paid': v.amount_paid, 'amount_due': v.amount_due, 'state': v.state, 'refunded': v.refunded, 'refund_amount': v.refund_amount, 'refund_reason': v.refund_reason or '', 'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None, 'notes': v.notes or '', 'cancel_reason': v.cancel_reason or '', 'cancelled_by': v.cancelled_by or '', 'cancelled_at': v.cancelled_at.strftime('%Y-%m-%d %H:%M') if v.cancelled_at else None, 'cancel_points_reversed': v.cancel_points_reversed, 'cancel_points_restored': v.cancel_points_restored, 'lines': [{ 'id': line.id, 'service_id': line.service_id.id if line.service_id else None, 'service': line.service_id.name if line.service_id else '', 'item_id': line.item_id.id if line.item_id else None, 'item': line.item_id.name if line.item_id else '', 'description': line.description or '', 'quantity': line.quantity, 'price_unit': line.price_unit, 'subtotal': line.subtotal, 'is_prescribed': line.is_prescribed, 'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None, 'prescribed_by': line.prescribed_by_id.name if line.prescribed_by_id else None, } for line in v.line_ids], } # ============================================================ # POS: cobro rápido (venta + pago en una operación) # ============================================================ @http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def pos_checkout(self, **kw): """Crea la venta confirmada, aplica canje de puntos y/o registra el pago. - pay_with_points: canjea puntos del monedero (1 pt = $1) por el total. A diferencia de action_apply_wallet (tope 50%), aquí el canje puede cubrir el 100% porque es la forma de pago elegida en caja. - action_mark_paid acumula puntos automáticamente (1 pt por $10 del total restante); no se duplica acumulación. """ try: data = _parse_json_body() lines = data.get('lines') or [] if not lines: return json_response({'status': 'error', 'message': 'La venta no tiene líneas'}, 400) partner = request.env['res.partner'].sudo().browse(int(data.get('partner_id', 0) or 0)) if not partner.exists(): return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404) discount = float(data.get('discount', 0) or 0) line_vals = [] item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar stock_warnings = [] subtotal = 0.0 for l in lines: qty = float(l.get('quantity', 0) or 0) price = float(l.get('price_unit', 0) or 0) if qty <= 0: return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400) service_id = int(l.get('service_id', 0) or 0) item_id = int(l.get('item_id', 0) or 0) if bool(service_id) == bool(item_id): return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400) subtotal += qty * price if item_id: item = request.env['skeen.inventario.item'].sudo().browse(item_id) if not item.exists() or not item.active: return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404) line_vals.append((0, 0, { 'item_id': item.id, 'description': l.get('description') or item.name, 'quantity': qty, 'price_unit': price, 'is_prescribed': bool(l.get('prescribed_by_id')), 'prescribed_by_id': l.get('prescribed_by_id') or False, })) item_lines.append((item, qty)) if qty > (item.qty or 0): stock_warnings.append(f'{item.name}: existencia {item.qty:g}, se vendieron {qty:g}') else: servicio = request.env['skeen.servicio'].sudo().browse(service_id) if not servicio.exists(): return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404) line_vals.append((0, 0, { 'service_id': servicio.id, 'description': l.get('description') or servicio.name, 'quantity': qty, 'price_unit': price, 'is_prescribed': bool(l.get('prescribed_by_id')), 'prescribed_by_id': l.get('prescribed_by_id') or False, })) total_est = max(0.0, subtotal - discount) # Moneda y tipo de cambio: validar ANTES de crear nada currency = data.get('currency', 'MXN') rate_used = None if currency == 'USD': tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1) if not tc: return json_response({'status': 'error', 'message': 'Configura el tipo de cambio USD en Configuración'}, 400) rate_used = tc.rate elif currency != 'MXN': return json_response({'status': 'error', 'message': 'Moneda no soportada (MXN/USD)'}, 400) # Validar saldo de puntos ANTES de crear nada pay_with_points = bool(data.get('pay_with_points')) Monedero = request.env['skeen.monedero'].sudo() monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1) saldo = monedero.points if monedero else 0 if pay_with_points and saldo < total_est: return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400) method = data.get('payment_method', 'cash') if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'): return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400) venta = request.env['skeen.venta'].sudo().create({ 'partner_id': partner.id, 'discount': discount, 'line_ids': line_vals, }) venta.action_confirm() # Descargo de inventario por cada artículo (el create del move ajusta el stock) if item_lines: Move = request.env['skeen.inventario.move'].sudo() for item, qty in item_lines: Move.create({ 'item_id': item.id, 'type': 'venta', 'qty': qty, 'reference': venta.name, 'notes': 'Venta POS', }) # Canje de puntos como forma de pago (hasta cubrir el total) puntos_usados = 0 if pay_with_points: total = venta.total puntos = int(total) if float(total).is_integer() else int(total) + 1 puntos = min(puntos, saldo) monedero.redeem_points(puntos, description=f'Canje puntos venta {venta.name}', reference=venta.name) venta.write({'points_redeemed': puntos, 'points_discount': float(puntos)}) puntos_usados = puntos # Pago monetario del restante restante = round(venta.amount_due, 2) pago = None cambio = 0.0 cambio_usd = 0.0 recibido_usd = 0.0 if restante > 0: received = float(data.get('amount_received', restante) or restante) reference = '' if currency == 'USD': # Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba) recibido_usd = received received = round(recibido_usd * rate_used, 2) reference = f'USD {recibido_usd:.2f} @ TC {rate_used:.2f}' pay_amount = restante if received >= restante - 0.001 else received cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0 if rate_used: cambio_usd = round(cambio / rate_used, 2) pago = request.env['skeen.pago'].sudo().create({ 'partner_id': partner.id, 'amount': pay_amount, 'payment_method': method, 'state': 'completed', 'payment_date': fields.Datetime.now(), 'provider_reference': reference, }) # 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí vals = {'amount_paid': venta.amount_paid + pay_amount} if method == 'card': vals['card_amount'] = venta.card_amount + pay_amount venta.write(vals) if venta.amount_paid >= venta.total: venta.action_mark_paid() else: venta.write({'state': 'partial'}) else: venta.action_mark_paid() partner.invalidate_recordset() return json_response({ 'status': 'success', 'sale': self._sale_to_dict(venta), 'payment': self._payment_to_dict(pago) if pago else None, 'cambio': cambio, 'cambio_usd': cambio_usd, 'rate_used': rate_used, 'recibido_usd': recibido_usd, 'puntos_usados': puntos_usados, 'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0, 'wallet_points': partner.wallet_points, 'stock_warnings': stock_warnings, }, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Cortes de Caja # ============================================================ @http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_cash_closings(self, **kw): try: domain = [] if kw.get('date'): domain.append(('date', '=', kw.get('date'))) closings = request.env['skeen.corte.caja'].sudo().search(domain, order='date desc', limit=100) # 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') 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') def close_cash_closing(self, closing_id, **kw): try: closing = request.env['skeen.corte.caja'].sudo().browse(closing_id) if not closing.exists(): return json_response({'status': 'error', 'message': 'Corte no encontrado'}, 404) closing.refresh_totals() data = _parse_json_body() vals = {} for key in ('closing_cash', 'counted_cash', 'counted_card', 'counted_transfer', 'counted_other'): if key in data: try: vals[key] = float(data[key] or 0) except (TypeError, ValueError): return json_response({'status': 'error', 'message': f'Valor inválido: {key}'}, 400) if 'notes' in data: vals['notes'] = data.get('notes', '') # Compatibilidad: si solo se envió el conteo, el cierre de efectivo # registrado es el contado (difference del listado usa closing_cash). if 'closing_cash' not in vals and 'counted_cash' in vals: vals['closing_cash'] = vals['counted_cash'] if vals: closing.write(vals) closing.action_close() return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _cash_closing_to_dict(self, c): return { 'id': c.id, 'name': c.name, 'date': c.date.strftime('%Y-%m-%d') if c.date else None, 'user': c.user_id.name, 'opening_cash': c.opening_cash, 'total_cash': c.total_cash, 'total_card': c.total_card, 'total_transfer': c.total_transfer, 'total_other': c.total_other, 'total_sales': c.total_sales, 'closing_cash': c.closing_cash, 'difference': c.difference, 'counted_cash': c.counted_cash, 'counted_card': c.counted_card, 'counted_transfer': c.counted_transfer, 'counted_other': c.counted_other, 'diff_cash': c.diff_cash, 'diff_card': c.diff_card, 'diff_transfer': c.diff_transfer, 'diff_other': c.diff_other, 'notes': c.notes or '', 'state': c.state, } # ============================================================ # Reportes # ============================================================ @http.route('/skeen/frontend/v1/reports/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_sales(self, **kw): try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) Ventas = request.env['skeen.venta'].sudo() # Las ventas canceladas (nota de crédito) no cuentan como ingreso ventas = Ventas.search([ ('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled'), ]) # Ingresos agrupados por día (clave nueva; lo demás no cambia) by_day_map = {} for v in ventas: key = v.date.strftime('%Y-%m-%d') by_day_map[key] = by_day_map.get(key, 0.0) + v.total return json_response({ 'status': 'success', 'start': start, 'end': end, 'total_sales': sum(v.total for v in ventas), 'total_paid': sum(v.amount_paid for v in ventas), 'total_due': sum(v.amount_due for v in ventas), 'count': len(ventas), 'by_day': [{'date': k, 'total': by_day_map[k]} for k in sorted(by_day_map)], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/adeudos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_adeudos(self, **kw): """Cartera por cobrar: adeudo agrupado por paciente (top 200)""" try: Ventas = request.env['skeen.venta'].sudo() rows = Ventas.read_group( [('amount_due', '>', 0), ('state', '!=', 'cancelled')], ['partner_id', 'amount_due:sum', 'date:max'], ['partner_id']) items = [] for r in rows: pid = r['partner_id'][0] items.append({ 'id': pid, 'name': r['partner_id'][1], 'num_ventas': r.get('__count') or r.get('partner_id_count', 0), 'total_adeudo': r['amount_due'], 'ultima_venta': str(r['date'])[:10] if r.get('date') else None, }) phones = {} partners = {} if items: for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]): phones[p.id] = p.phone or '' partners[p.id] = p for i in items: i['phone'] = phones.get(i['id'], '') plan = self._payment_plan_dict(partners[i['id']]) if i['id'] in partners else None i['plan'] = plan if plan and plan['count'] > 0 else None items.sort(key=lambda x: -x['total_adeudo']) total_cartera = sum(i['total_adeudo'] for i in items) return json_response({ 'status': 'success', 'adeudos': items[:200], 'total_cartera': total_cartera, 'total_pacientes': len(items), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/daily', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_daily(self, **kw): """Resumen de movimientos del día: citas, ventas, cobros e inventario""" try: date_str = kw.get('date', date.today().strftime('%Y-%m-%d')) # Citas del día por estado citas = request.env['skeen.cita'].sudo().search([('date', '=', date_str)]) citas_by_state = {} for c in citas: citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1 # Ventas del día (excluye canceladas: no son ingreso) ventas = request.env['skeen.venta'].sudo().search([ ('date', '=', date_str), ('state', '!=', 'cancelled'), ]) # Cobros del día por método (misma convención que report_cash) pagos = request.env['skeen.pago'].sudo().search([ ('payment_date', '>=', f'{date_str} 00:00:00'), ('payment_date', '<=', f'{date_str} 23:59:59'), ('state', '=', 'completed'), ]) pagos_by_method = {} for p in pagos: pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount # Movimientos de inventario del día por tipo moves = request.env['skeen.inventario.move'].sudo().search([ ('date', '>=', f'{date_str} 00:00:00'), ('date', '<=', f'{date_str} 23:59:59'), ]) moves_by_type = {} for mv in moves: moves_by_type[mv.type] = moves_by_type.get(mv.type, 0) + 1 response = { 'status': 'success', 'date': date_str, 'citas': {'total': len(citas), 'by_state': citas_by_state}, 'ventas': { 'count': len(ventas), 'total': sum(v.total for v in ventas), 'total_paid': sum(v.amount_paid for v in ventas), 'total_due': sum(v.amount_due for v in ventas), }, 'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method}, 'inventario': {'total': len(moves), 'by_type': moves_by_type}, } # Desglose opcional por médico o por usuario de recepción by = kw.get('by') if by == 'medico': response['por_medico'] = self._daily_por_medico(citas, ventas, pagos) elif by == 'recepcion': response['por_recepcion'] = self._daily_por_recepcion(ventas, pagos) return json_response(response) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _daily_por_medico(self, citas, ventas, pagos): """Desglose del día por médico: citas (por estado), líneas recetadas y cobros de sus citas""" por_medico = {} for c in citas: doc = c.doctor_id.name if c.doctor_id else 'Sin médico' entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0}) entry['citas'] += 1 entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1 # Ventas del día atribuidas al médico vía líneas recetadas if ventas: lineas = request.env['skeen.venta.line'].sudo().search([ ('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False), ]) for l in lineas: doc = l.prescribed_by_id.name entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0}) entry['ventas'] += l.subtotal # Cobros del día ligados a cita con médico for p in pagos: if p.cita_id and p.cita_id.doctor_id: doc = p.cita_id.doctor_id.name entry = por_medico.setdefault(doc, {'citas': 0, 'by_state': {}, 'ventas': 0.0, 'cobros': 0.0}) entry['cobros'] += p.amount rows = [{'medico': k, **v} for k, v in por_medico.items()] rows.sort(key=lambda r: -(r['ventas'] + r['cobros'] + r['citas'])) return rows def _daily_por_recepcion(self, ventas, pagos): """Desglose del día por usuario que registró la venta / cobro (create_uid)""" por_usuario = {} for v in ventas: usr = v.create_uid.name entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0}) entry['ventas_count'] += 1 entry['ventas_total'] += v.total for p in pagos: usr = p.create_uid.name entry = por_usuario.setdefault(usr, {'ventas_count': 0, 'ventas_total': 0.0, 'cobros_total': 0.0}) entry['cobros_total'] += p.amount rows = [{'usuario': k, **v} for k, v in por_usuario.items()] rows.sort(key=lambda r: -(r['ventas_total'] + r['cobros_total'])) return rows @http.route('/skeen/frontend/v1/reports/comisiones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_comisiones(self, **kw): """Comisiones por empleado: líneas recetadas + citas completadas""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) ventas = request.env['skeen.venta'].sudo().search([ ('state', 'in', ['confirmed', 'paid', 'partial']), ('date', '>=', start), ('date', '<=', end), ]) rows = [] if ventas: Lineas = request.env['skeen.venta.line'].sudo() line_domain = [('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)] grouped = Lineas.read_group(line_domain, ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id']) ventas_por_doc = {} items_por_doc = {} for r in Lineas.search_read(line_domain, ['prescribed_by_id', 'venta_id']): did = r['prescribed_by_id'][0] ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0]) items_por_doc[did] = items_por_doc.get(did, 0) + 1 # Citas completadas por doctor en el rango citas_por_doc = {} for g in request.env['skeen.cita'].sudo().read_group( [('state', '=', 'done'), ('date', '>=', start), ('date', '<=', end), ('doctor_id', '!=', False)], ['doctor_id'], ['doctor_id']): citas_por_doc[g['doctor_id'][0]] = g.get('__count') or g.get('doctor_id_count', 0) Emp = request.env['hr.employee'].sudo() for g in grouped: did = g['prescribed_by_id'][0] emp = Emp.browse(did) base = g['subtotal'] or 0.0 pct = emp.commission_pct or 0.0 rows.append({ 'doctor_id': emp.id, 'doctor': emp.name, 'job_title': emp.job_title or '', 'items': items_por_doc.get(did, 0), 'sales': len(ventas_por_doc.get(did, set())), 'citas_done': citas_por_doc.get(did, 0), 'total_vendido': base, 'commission_pct': pct, 'comision': base * pct / 100.0, }) rows.sort(key=lambda r: -r['comision']) return json_response({ 'status': 'success', 'start': start, 'end': end, 'comisiones': rows, 'total_vendido': sum(r['total_vendido'] for r in rows), 'total_comision': sum(r['comision'] for r in rows), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/pagos-servicios', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_pagos_servicios(self, **kw): """Cobros del periodo y desglose por servicio de las ventas totalmente pagadas. skeen.pago no tiene relación directa con venta/servicio, así que el desglose por servicio usa las ventas state='paid' del rango (subtotal por línea). El KPI de cobrado usa los pagos reales del periodo. """ try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) pagos = request.env['skeen.pago'].sudo().search([ ('payment_date', '>=', f'{start} 00:00:00'), ('payment_date', '<=', f'{end} 23:59:59'), ('state', '=', 'completed'), ]) by_method = {} for p in pagos: by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount # Desglose por servicio: líneas de ventas totalmente pagadas en el rango ventas_paid = request.env['skeen.venta'].sudo().search([ ('state', '=', 'paid'), ('date', '>=', start), ('date', '<=', end), ]) servicios = {} if ventas_paid: grouped = request.env['skeen.venta.line'].sudo().read_group( [('venta_id', 'in', ventas_paid.ids)], ['service_id', 'subtotal:sum', 'quantity:sum'], ['service_id']) for g in grouped: sid = g['service_id'][0] if g['service_id'] else 0 servicios[sid] = { 'servicio': g['service_id'][1] if g['service_id'] else 'Sin servicio', 'cantidad': g['quantity'], 'total': g['subtotal'], } rows = sorted(servicios.values(), key=lambda x: -x['total']) return json_response({ 'status': 'success', 'start': start, 'end': end, 'total_cobrado': sum(p.amount for p in pagos), 'total_pagos': len(pagos), 'by_method': by_method, 'servicios': rows, 'total_pagado_completo': sum(r['total'] for r in rows), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/pagos-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_pagos_clientes(self, **kw): """Cobros agrupados por cliente (top 200)""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) rows = request.env['skeen.pago'].sudo().read_group([ ('payment_date', '>=', f'{start} 00:00:00'), ('payment_date', '<=', f'{end} 23:59:59'), ('state', '=', 'completed'), ], ['partner_id', 'amount:sum', 'payment_date:max'], ['partner_id']) items = [{ 'id': r['partner_id'][0], 'name': r['partner_id'][1], 'num_pagos': r.get('__count') or r.get('partner_id_count', 0), 'total_cobrado': r['amount'], 'ultimo_pago': str(r['payment_date'])[:16] if r.get('payment_date') else None, } for r in rows] items.sort(key=lambda x: -x['total_cobrado']) return json_response({ 'status': 'success', 'start': start, 'end': end, 'clientes': items[:200], 'total_cobrado': sum(i['total_cobrado'] for i in items), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/devoluciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_devoluciones(self, **kw): """Ventas devueltas en el periodo""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) ventas = request.env['skeen.venta'].sudo().search([ ('refunded', '=', True), ('date', '>=', start), ('date', '<=', end), ], order='date desc') rows = [{ 'id': v.id, 'folio': v.name, 'paciente': v.partner_id.name, 'fecha': v.date.strftime('%Y-%m-%d') if v.date else None, 'total': v.total, 'refund_amount': v.refund_amount, 'refund_reason': v.refund_reason or '', 'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None, } for v in ventas] return json_response({ 'status': 'success', 'start': start, 'end': end, 'devoluciones': rows, 'total_devuelto': sum(r['refund_amount'] for r in rows), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/top-clientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_top_clientes(self, **kw): """Clientes con más compras en el periodo""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) limit = min(max(int(kw.get('limit', 20) or 20), 1), 200) rows = request.env['skeen.venta'].sudo().read_group( [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)], ['partner_id', 'total:sum'], ['partner_id']) items = [] for r in rows: n = r.get('__count') or r.get('partner_id_count', 0) items.append({ 'id': r['partner_id'][0], 'name': r['partner_id'][1], 'num_ventas': n, 'total': r['total'], 'ticket_promedio': (r['total'] / n) if n else 0.0, }) items.sort(key=lambda x: -x['total']) return json_response({ 'status': 'success', 'start': start, 'end': end, 'clientes': items[:limit], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/kpis', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_kpis(self, **kw): """KPIs clínicos: no-show, ocupación por médico, conversión de leads, primera vez""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) Citas = request.env['skeen.cita'].sudo() citas = Citas.search([('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled')]) # No-show: tasa global y por médico total_citas = len(citas) total_no_show = len(citas.filtered(lambda c: c.state == 'no_show')) por_medico = {} for c in citas: doc = c.doctor_id.name if c.doctor_id else 'Sin médico' entry = por_medico.setdefault(doc, {'citas': 0, 'no_shows': 0}) entry['citas'] += 1 if c.state == 'no_show': entry['no_shows'] += 1 noshow_medico = [{ 'medico': k, 'citas': v['citas'], 'no_shows': v['no_shows'], 'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0, } for k, v in por_medico.items()] noshow_medico.sort(key=lambda x: -x['tasa']) # Ocupación por médico: horas vendidas vs disponibles (días no domingo × 8h − bloqueos) d1 = datetime.strptime(start, '%Y-%m-%d').date() d2 = datetime.strptime(end, '%Y-%m-%d').date() dias_habiles = sum(1 for i in range((d2 - d1).days + 1) if (d1 + timedelta(days=i)).weekday() != 6) bloqueos = request.env['skeen.cita.bloqueo'].sudo().search([ ('date', '>=', start), ('date', '<=', end), ]) bloqueo_horas = {} for b in bloqueos: if b.date.weekday() == 6: continue horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from) bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas ventas_medico = {} for c in citas: if not c.doctor_id: continue dur = (c.servicio_id.duration_min if c.servicio_id else 30) or 30 ventas_medico[c.doctor_id.name] = ventas_medico.get(c.doctor_id.name, 0.0) + dur / 60.0 ocupacion = [] for medico, vendidas in ventas_medico.items(): disponibles = dias_habiles * 8.0 - bloqueo_horas.get(medico, 0.0) disponibles = max(disponibles, 0.0) pct = vendidas / disponibles if disponibles > 0 else 0 ocupacion.append({ 'medico': medico, 'horas_vendidas': round(vendidas, 2), 'horas_disponibles': round(disponibles, 2), 'horas_libres': round(max(disponibles - vendidas, 0.0), 2), 'ocupacion': round(min(pct, 1.0), 4), }) ocupacion.sort(key=lambda x: -x['ocupacion']) ocup_promedio = (sum(o['ocupacion'] for o in ocupacion) / len(ocupacion)) if ocupacion else 0 # Conversión de leads WACRM (created_at en el rango) deals = request.env['skeen.wacrm.deal'].sudo().search([ ('created_at', '>=', f'{start} 00:00:00'), ('created_at', '<=', f'{end} 23:59:59'), ]) won = deals.filtered(lambda d: d.status == 'won') tiempos = [] for d in won: if d.created_at and d.updated_at and d.updated_at > d.created_at: tiempos.append((d.updated_at - d.created_at).total_seconds() / 86400.0) conversion = { 'total': len(deals), 'ganados': len(won), 'tasa': round(len(won) / len(deals), 4) if deals else 0, 'dias_promedio_ganar': round(sum(tiempos) / len(tiempos), 1) if tiempos else None, } # Primera vez vs subsecuentes (primera cita del paciente, misma lógica que list_appointments) primera = 0 if citas: partner_ids = list({c.partner_id.id for c in citas}) groups = Citas.read_group( [('partner_id', 'in', partner_ids), ('state', '!=', 'cancelled')], ['date:min'], ['partner_id']) min_dates = {g['partner_id'][0]: str(g['date'])[:10] for g in groups} primera = sum(1 for c in citas if min_dates.get(c.partner_id.id) == str(c.date)[:10]) return json_response({ 'status': 'success', 'start': start, 'end': end, 'no_show': { 'tasa': round(total_no_show / total_citas, 4) if total_citas else 0, 'total_citas': total_citas, 'total_no_shows': total_no_show, 'por_medico': noshow_medico, }, 'ocupacion': { 'promedio': round(ocup_promedio, 4), 'dias_habiles': dias_habiles, 'por_medico': ocupacion, }, 'leads': conversion, 'primera_vez': { 'primera': primera, 'subsecuentes': total_citas - primera, 'tasa': round(primera / total_citas, 4) if total_citas else 0, }, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/notifications', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def notifications(self, **kw): """Alertas dinámicas para la campanita del header (calculadas al momento)""" try: hoy = date.today() items = [] Item = request.env['skeen.inventario.item'].sudo() # conteo exacto con la misma regla de /inventario/alertas (bajo mínimo o sin existencia) stock_bajo = 0 for it in Item.search([('active', '=', True)]): qty = it.qty or 0.0 qty_min = it.qty_min or 0.0 if qty < qty_min or (qty_min == 0 and qty == 0): stock_bajo += 1 if stock_bajo: items.append({ 'tipo': 'stock_bajo', 'titulo': 'Stock bajo', 'detalle': f'{stock_bajo} artículos bajo su mínimo', 'link': '/inventario?tab=alertas', 'severity': 'warning', }) caduc = Item.search_count([ ('active', '=', True), ('expiry_date', '!=', False), ('expiry_date', '<=', (hoy + timedelta(days=30)).strftime('%Y-%m-%d')), ]) if caduc: items.append({ 'tipo': 'caducidades', 'titulo': 'Caducidades', 'detalle': f'{caduc} artículos vencidos o por vencer en 30 días', 'link': '/inventario?tab=alertas', 'severity': 'danger', }) pendientes_hoy = request.env['skeen.cita'].sudo().search_count([ ('date', '=', hoy.strftime('%Y-%m-%d')), ('state', '=', 'pending'), ]) if pendientes_hoy: items.append({ 'tipo': 'citas_pendientes_hoy', 'titulo': 'Citas por confirmar', 'detalle': f'{pendientes_hoy} citas pendientes hoy', 'link': '/agenda', 'severity': 'info', }) leads_sin_asignar = request.env['skeen.wacrm.deal'].sudo().search_count([ ('status', '=', 'open'), '|', ('assigned_to', '=', False), ('assigned_to', '=', ''), ]) if leads_sin_asignar: items.append({ 'tipo': 'leads_sin_asignar', 'titulo': 'Leads sin asignar', 'detalle': f'{leads_sin_asignar} leads abiertos sin agente', 'link': '/wacrm/leads', 'severity': 'warning', }) convers = request.env['skeen.wacrm.conversation'].sudo().search([('unread_count', '>', 0)]) sin_leer = sum(c.unread_count for c in convers) if sin_leer: items.append({ 'tipo': 'conversaciones_sin_leer', 'titulo': 'Mensajes sin leer', 'detalle': f'{sin_leer} mensajes no leídos en {len(convers)} conversaciones', 'link': '/wacrm/messages', 'severity': 'info', }) return json_response({ 'status': 'success', 'notifications': items, 'total': len(items), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/horas-agenda', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_horas_agenda(self, **kw): """Citas agrupadas por hora del día (8-19) con desglose por estado""" try: start = kw.get('start', date.today().strftime('%Y-%m-%d')) end = kw.get('end', start) citas = request.env['skeen.cita'].sudo().search([ ('date', '>=', start), ('date', '<=', end), ('state', 'not in', ('cancelled', 'no_show')), ]) por_hora = {} for c in citas: hora = int(c.time or 0) entry = por_hora.setdefault(hora, {'total': 0, 'by_state': {}}) entry['total'] += 1 entry['by_state'][c.state] = entry['by_state'].get(c.state, 0) + 1 rows = [] for hora in range(8, 20): entry = por_hora.get(hora, {'total': 0, 'by_state': {}}) rows.append({'hora': f'{hora:02d}:00', 'total': entry['total'], 'by_state': entry['by_state']}) return json_response({ 'status': 'success', 'start': start, 'end': end, 'horas': rows, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/paquetes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_paquetes(self, **kw): """Servicios vendidos como paquete: sesiones, ventas y terminados""" try: servicios = request.env['skeen.servicio'].sudo().search([ ('package_sessions', '>', 0), ('active', '=', True), ], order='name asc') citas_count = {} citas_finished = {} if servicios: Citas = request.env['skeen.cita'].sudo() for g in Citas.read_group( [('servicio_id', 'in', servicios.ids), ('state', '!=', 'cancelled')], ['servicio_id'], ['servicio_id']): citas_count[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0) for g in Citas.read_group( [('servicio_id', 'in', servicios.ids), ('package_finished', '=', True)], ['servicio_id'], ['servicio_id']): citas_finished[g['servicio_id'][0]] = g.get('__count') or g.get('servicio_id_count', 0) rows = [{ 'id': s.id, 'name': s.name, 'package_sessions': s.package_sessions, 'package_price': s.package_price, 'price': s.price, 'citas': citas_count.get(s.id, 0), 'terminados': citas_finished.get(s.id, 0), } for s in servicios] return json_response({'status': 'success', 'paquetes': rows}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/vendedores', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_vendedores(self, **kw): """Ventas del periodo agrupadas por usuario que las registró (create_uid)""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) rows = request.env['skeen.venta'].sudo().read_group( [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)], ['create_uid', 'total:sum', 'amount_paid:sum'], ['create_uid']) items = [{ 'usuario': r['create_uid'][1] if r['create_uid'] else 'Sin usuario', 'num_ventas': r.get('__count') or r.get('create_uid_count', 0), 'total': r['total'], 'cobrado': r['amount_paid'], } for r in rows] items.sort(key=lambda x: -x['total']) return json_response({ 'status': 'success', 'start': start, 'end': end, 'vendedores': items, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/concentrado', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_concentrado(self, **kw): """Totales del periodo en una sola respuesta""" try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) citas = request.env['skeen.cita'].sudo().search([('date', '>=', start), ('date', '<=', end)]) citas_by_state = {} for c in citas: citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1 ventas = request.env['skeen.venta'].sudo().search([ ('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end), ]) pagos = request.env['skeen.pago'].sudo().search([ ('payment_date', '>=', f'{start} 00:00:00'), ('payment_date', '<=', f'{end} 23:59:59'), ('state', '=', 'completed'), ]) pagos_by_method = {} for p in pagos: pagos_by_method[p.payment_method] = pagos_by_method.get(p.payment_method, 0) + p.amount devoluciones = request.env['skeen.venta'].sudo().search([ ('refunded', '=', True), ('date', '>=', start), ('date', '<=', end), ]) pacientes_nuevos = request.env['res.partner'].sudo().search_count([ ('is_patient', '=', True), ('create_date', '>=', f'{start} 00:00:00'), ('create_date', '<=', f'{end} 23:59:59'), ]) visitas = request.env['skeen.visita'].sudo().search([ ('date_start', '>=', f'{start} 00:00:00'), ('date_start', '<=', f'{end} 23:59:59'), ]) visitas_by_state = {} for v in visitas: visitas_by_state[v.state] = visitas_by_state.get(v.state, 0) + 1 return json_response({ 'status': 'success', 'start': start, 'end': end, 'citas': {'total': len(citas), 'by_state': citas_by_state}, 'ventas': { 'count': len(ventas), 'total': sum(v.total for v in ventas), 'total_paid': sum(v.amount_paid for v in ventas), 'total_due': sum(v.amount_due for v in ventas), }, 'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method}, 'devoluciones': {'count': len(devoluciones), 'total': sum(v.refund_amount for v in devoluciones)}, 'pacientes_nuevos': pacientes_nuevos, 'visitas': {'total': len(visitas), 'by_state': visitas_by_state}, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/recomendaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_recomendaciones(self, **kw): """Pacientes agrupados por quién los recomendó""" try: rows = request.env['res.partner'].sudo().read_group( [('is_patient', '=', True), ('referred_by', 'not in', (False, ''))], ['referred_by'], ['referred_by']) items = [{ 'recomendador': r['referred_by'], 'pacientes': r.get('__count') or r.get('referred_by_count', 0), } for r in rows if r['referred_by']] items.sort(key=lambda x: -x['pacientes']) return json_response({ 'status': 'success', 'recomendaciones': items[:200], 'total': sum(i['pacientes'] for i in items), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/monedero-puntos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_monedero_puntos(self, **kw): """Puntos del monedero: emitidos, canjeados y saldo activo""" try: Tx = request.env['skeen.monedero.transaction'].sudo() domain = [] if kw.get('start'): domain.append(('create_date', '>=', f"{kw['start']} 00:00:00")) if kw.get('end'): domain.append(('create_date', '<=', f"{kw['end']} 23:59:59")) by_type = {} for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']): by_type[g['type']] = g['points'] monederos = request.env['skeen.monedero'].sudo().search([]) return json_response({ 'status': 'success', 'by_type': by_type, 'puntos_activos': sum(m.points for m in monederos), 'cuentas': len(monederos), 'equivalente_mxn': sum(m.points for m in monederos) * 1.0, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_appointments(self, **kw): try: start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) Citas = request.env['skeen.cita'].sudo() citas = Citas.search([('date', '>=', start), ('date', '<=', end)]) by_state = {} for c in citas: by_state[c.state] = by_state.get(c.state, 0) + 1 return json_response({ 'status': 'success', 'start': start, 'end': end, 'total': len(citas), 'by_state': by_state, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/cash', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_cash(self, **kw): try: date_str = kw.get('date', date.today().strftime('%Y-%m-%d')) # Rango opcional (start/end); si no viene, comportamiento original por día if kw.get('start') and kw.get('end'): date_from, date_to = kw['start'], kw['end'] else: date_from = date_to = date_str Pagos = request.env['skeen.pago'].sudo() pagos = Pagos.search([ ('payment_date', '>=', f'{date_from} 00:00:00'), ('payment_date', '<=', f'{date_to} 23:59:59'), ('state', '=', 'completed'), ]) by_method = {} for p in pagos: by_method[p.payment_method] = by_method.get(p.payment_method, 0) + p.amount return json_response({ 'status': 'success', 'date': date_str, 'total': sum(p.amount for p in pagos), 'by_method': by_method, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/inventory', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_inventory(self, **kw): """Reporte de stock: KPIs de valor y niveles + detalle por artículo""" try: items = request.env['skeen.inventario.item'].sudo().search([('active', '=', True)], order='name asc') return json_response({ 'status': 'success', 'summary': { 'total_value': sum(i.inventory_value or 0.0 for i in items), 'count': len(items), 'out': sum(1 for i in items if i.stock_level == 'out'), 'critical': sum(1 for i in items if i.stock_level == 'critical'), }, 'items': [self._item_to_dict(i) for i in items], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reminders/pending', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def pending_reminders(self, **kw): """Devuelve recordatorios pendientes: citas 24h, follow-ups 7d, re-engagement 90d.""" try: today = date.today() tomorrow = today + timedelta(days=1) seven_days_ago = today - timedelta(days=7) ninety_days_ago = today - timedelta(days=90) Citas = request.env['skeen.cita'].sudo() Pacientes = request.env['res.partner'].sudo() # Recordatorios 24h antes citas_manana = Citas.search([ ('date', '=', tomorrow), ('state', '=', 'confirmed'), ]) reminders = [] for c in citas_manana: if c.paciente_id and c.paciente_id.phone: reminders.append({ 'type': 'appointment_reminder', 'phone': c.paciente_id.phone, 'patient_name': c.paciente_id.name, 'appointment_id': c.id, 'reference': c.name, 'service': c.servicio_id.name if c.servicio_id else '', 'date': str(c.date), 'time': c.time, }) # Follow-ups 7 días después citas_semana = Citas.search([ ('date', '=', seven_days_ago), ('state', '=', 'done'), ]) followups = [] for c in citas_semana: if c.paciente_id and c.paciente_id.phone: followups.append({ 'type': 'followup', 'phone': c.paciente_id.phone, 'patient_name': c.paciente_id.name, 'appointment_id': c.id, 'service': c.servicio_id.name if c.servicio_id else '', 'date': str(c.date), }) # Re-engagement 90 días (solo pacientes con last_visit real) pacientes_inactivos = Pacientes.search([ ('is_patient', '=', True), ('last_visit', '!=', False), ('last_visit', '<=', ninety_days_ago), ], limit=50) reengagements = [] for p in pacientes_inactivos: if p.phone: reengagements.append({ 'type': 'reengagement', 'phone': p.phone, 'patient_name': p.name, 'last_visit': str(p.last_visit) if p.last_visit else None, }) return json_response({ 'status': 'success', 'reminders': reminders, 'followups': followups, 'reengagements': reengagements, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/reports/top_services', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_top_services(self, **kw): try: start = kw.get('start', date.today().replace(day=1).strftime('%Y-%m-%d')) end = kw.get('end', date.today().strftime('%Y-%m-%d')) limit = min(max(int(kw.get('limit', 8) or 8), 1), 50) ventas = request.env['skeen.venta'].sudo().search([ ('date', '>=', start), ('date', '<=', end), ('state', 'in', ['confirmed', 'paid', 'partial']), ]) rows = [] if ventas: grouped = request.env['skeen.venta.line'].sudo().read_group( [('venta_id', 'in', ventas.ids)], ['service_id', 'subtotal:sum', 'quantity:sum'], ['service_id'], orderby='subtotal desc', limit=limit, ) for g in grouped: rows.append({ 'service_id': g['service_id'][0], 'service': g['service_id'][1], 'qty': g.get('quantity', 0), 'revenue': g.get('subtotal', 0.0), }) return json_response({'status': 'success', 'start': start, 'end': end, 'services': rows}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Tipo de cambio USD/MXN # ============================================================ @http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_exchange_rate(self, **kw): try: TC = request.env['skeen.tipo.cambio'].sudo() current = TC.search([], order='date desc, id desc', limit=1) history = TC.search([], order='date desc, id desc', limit=30) return json_response({ 'status': 'success', 'current': { 'rate': current.rate if current else 0.0, 'date': current.date.strftime('%Y-%m-%d') if current else None, 'source': current.source if current else '', } if current else None, 'history': [{ 'id': r.id, 'date': r.date.strftime('%Y-%m-%d'), 'rate': r.rate, 'source': r.source or '', } for r in history], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False) @require_role('admin') def set_exchange_rate(self, **kw): try: data = _parse_json_body() rate = float(data.get('rate') or 0) if rate <= 0: return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400) today = fields.Date.today() TC = request.env['skeen.tipo.cambio'].sudo() existing = TC.search([('date', '=', today)], limit=1) prev_rate = existing.rate if existing else None vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')} if existing: existing.write(vals) rec = existing else: rec = TC.create({'date': today, **vals}) self._auditar('tipo_cambio', f'tipo_cambio:{rec.id}', 'rate', prev_rate, rate, entity_label=f'TC {today}', notes='Alta' if prev_rate is None else 'Actualización') return json_response({ 'status': 'success', 'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source}, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Meta de ventas (mensual) vs. avance actual # ============================================================ @http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def get_sales_goal(self, **kw): try: ICP = request.env['ir.config_parameter'].sudo() goal = float(ICP.get_param('skeen.sales_goal_monthly', 0) or 0) today = date.today() start = today.replace(day=1) ventas = request.env['skeen.venta'].sudo().search([ ('date', '>=', start), ('date', '<=', today), ('state', 'in', ['confirmed', 'paid', 'partial']), ]) current = sum(v.total for v in ventas) pct = (current / goal * 100.0) if goal > 0 else 0.0 return json_response({ 'status': 'success', 'goal': goal, 'current': current, 'pct': min(pct, 999.0), 'start': start.strftime('%Y-%m-%d'), 'end': today.strftime('%Y-%m-%d'), 'count': len(ventas), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') def set_sales_goal(self, **kw): try: data = _parse_json_body() goal = float(data.get('goal') or 0) if goal < 0: return json_response({'status': 'error', 'message': 'goal debe ser >= 0'}, 400) request.env['ir.config_parameter'].sudo().set_param('skeen.sales_goal_monthly', goal) return json_response({'status': 'success', 'goal': goal}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') def get_commission_default(self, **kw): """% de comisión global por defecto (se aplica a médicos sin % propio).""" try: ICP = request.env['ir.config_parameter'].sudo() pct = float(ICP.get_param('skeen.commission_pct', 0) or 0) return json_response({'status': 'success', 'commission_pct': pct}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') def set_commission_default(self, **kw): try: data = _parse_json_body() try: pct = float(data.get('commission_pct')) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400) if not 0 <= pct <= 100: return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400) ICP = request.env['ir.config_parameter'].sudo() prev = float(ICP.get_param('skeen.commission_pct', 0) or 0) ICP.set_param('skeen.commission_pct', pct) self._auditar('comisiones', 'commission_default', 'commission_pct', prev, pct, entity_label='Comisión global') return json_response({'status': 'success', 'commission_pct': pct}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Simulación de impacto y auditoría de configuración # ============================================================ @http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') def config_impact_simulation(self, **kw): """Qué habría pasado con los valores configurados (o unos propuestos). Calcula, con las ventas atribuidas REALES del periodo, cuánto gana cada médico por comisión. Parámetros opcionales `commission_pct` y `rate` permiten probar valores candidatos sin guardarlos. """ try: start = kw.get('date_from') or kw.get('start') end = kw.get('date_to') or kw.get('end') ICP = request.env['ir.config_parameter'].sudo() pct_global = float(ICP.get_param('skeen.commission_pct', 0) or 0) propuesto = kw.get('commission_pct') pct_propuesto = None if propuesto not in (None, ''): try: pct_propuesto = float(propuesto) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400) if not 0 <= pct_propuesto <= 100: return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400) vdomain = [('state', '!=', 'cancelled')] if start: vdomain.append(('date', '>=', start)) if end: vdomain.append(('date', '<=', end)) ventas = request.env['skeen.venta'].sudo().search(vdomain) lineas = request.env['skeen.venta.line'].sudo().search([ ('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False), ]) base_por_doc = {} for l in lineas: did = l.prescribed_by_id.id base_por_doc[did] = base_por_doc.get(did, 0.0) + (l.subtotal or 0.0) Emp = request.env['hr.employee'].sudo() filas = [] total_base = 0.0 total_comision = 0.0 for did, base in base_por_doc.items(): emp = Emp.browse(did) propio = emp.commission_pct or 0.0 if pct_propuesto is not None: pct, origen = pct_propuesto, 'propuesto' elif propio > 0: pct, origen = propio, 'propio' else: pct, origen = pct_global, 'global' comision = base * pct / 100.0 total_base += base total_comision += comision filas.append({ 'doctor_id': did, 'doctor': emp.name, 'job_title': emp.job_title or '', 'base': round(base, 2), 'commission_pct_propio': propio, 'commission_pct_efectivo': pct, 'origen_pct': origen, 'commission': round(comision, 2), }) filas.sort(key=lambda f: -f['commission']) # Equivalente USD del periodo con el TC vigente (o uno propuesto) TC = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1) rate_actual = TC.rate if TC else None rate = rate_actual if kw.get('rate') not in (None, ''): try: rate = float(kw.get('rate')) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'rate debe ser numérico'}, 400) if rate <= 0: return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400) return json_response({ 'status': 'success', 'date_from': start, 'date_to': end, 'commission_pct_global': pct_global, 'commission_pct_propuesto': pct_propuesto, 'doctores': filas, 'total_base': round(total_base, 2), 'total_commission': round(total_comision, 2), 'ventas': len(ventas), 'rate': rate, 'rate_actual': rate_actual, 'total_usd': round(total_base / rate, 2) if rate else None, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') def config_audit_list(self, **kw): """Bitácora de cambios de configuración (filtrable y paginada).""" try: domain = [] if kw.get('date_from'): domain.append(('date', '>=', f"{kw['date_from']} 00:00:00")) if kw.get('date_to'): domain.append(('date', '<=', f"{kw['date_to']} 23:59:59")) if kw.get('module'): domain.append(('module', '=', kw['module'])) if kw.get('user'): domain += ['|', ('user_login', 'ilike', kw['user']), ('user_name', 'ilike', kw['user'])] if kw.get('entity'): e = kw['entity'] domain += ['|', ('entity', 'ilike', e), ('entity_label', 'ilike', e)] page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) Audit = request.env['skeen.config.audit'].sudo() total = Audit.search_count(domain) recs = Audit.search(domain, order='date desc, id desc', limit=page_size, offset=(page - 1) * page_size) return json_response({ 'status': 'success', 'registros': [{ 'id': r.id, 'date': r.date.strftime('%Y-%m-%d %H:%M') if r.date else None, 'user_login': r.user_login or '', 'user_name': r.user_name or '', 'user_role': r.user_role or '', 'module': r.module, 'module_label': dict( request.env['skeen.config.audit']._fields['module'].selection).get(r.module, r.module), 'entity': r.entity or '', 'entity_label': r.entity_label or '', 'field': r.field or '', 'old_value': r.old_value or '', 'new_value': r.new_value or '', 'notes': r.notes or '', } for r in recs], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Reporte de cumpleañeros # ============================================================ @http.route('/skeen/frontend/v1/reports/birthdays', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def report_birthdays(self, **kw): try: import calendar period = kw.get('period', 'month') today = date.today() if period == 'today': start = end = today elif period == 'week': start = today end = today + timedelta(days=7) else: # month (mes actual) start = today.replace(day=1) end = today.replace(day=calendar.monthrange(today.year, today.month)[1]) pacientes = request.env['res.partner'].sudo().search([ ('is_patient', '=', True), ('birth_date', '!=', False), ]) matched = [] for p in pacientes: b = p.birth_date cands = [] for yr in (today.year, today.year + 1): try: cands.append(b.replace(year=yr)) except ValueError: # 29-feb en año no bisiesto cands.append(b.replace(year=yr, day=28)) hit = next((o for o in cands if start <= o <= end), None) if hit: matched.append((p, hit)) matched.sort(key=lambda t: (t[1].month, t[1].day)) matched = matched[:200] # Adeudo por paciente en una sola consulta agrupada ids = [p.id for p, _ in matched] adeudo_map = {} if ids: rows = request.env['skeen.venta'].sudo().read_group( [('partner_id', 'in', ids), ('state', 'in', ['confirmed', 'paid', 'partial'])], ['partner_id', 'amount_due:sum'], ['partner_id'], ) for r in rows: adeudo_map[r['partner_id'][0]] = r['amount_due'] items = [{ 'id': p.id, 'name': p.name, 'phone': p.phone or '', 'whatsapp': p.whatsapp or '', 'birth_date': p.birth_date.strftime('%Y-%m-%d'), 'occurs_on': occ.strftime('%Y-%m-%d'), 'turning_age': occ.year - p.birth_date.year, 'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None, 'total_spent': p.total_spent, 'amount_due': adeudo_map.get(p.id, 0.0), 'is_vip': p.is_vip, } for p, occ in matched] return json_response({ 'status': 'success', 'period': period, 'start': start.strftime('%Y-%m-%d'), 'end': end.strftime('%Y-%m-%d'), 'total': len(items), 'birthdays': items, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Inventario / Consumibles # ============================================================ def _item_to_dict(self, it): return { 'id': it.id, 'name': it.name, 'kind': it.kind, 'sku': it.sku or '', 'category': it.category or '', 'unit': it.unit or 'pieza', 'qty': it.qty, 'qty_optimal': it.qty_optimal, 'qty_min': it.qty_min, 'cost': it.cost, 'price': it.price, 'inventory_value': it.inventory_value, 'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None, 'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None, 'active': it.active, 'genera_monedero': it.genera_monedero, 'notes': it.notes or '', 'stock_level': it.stock_level, } def _move_to_dict(self, m): return { 'id': m.id, 'item_id': m.item_id.id, 'type': m.type, 'qty': m.qty, 'before_qty': m.before_qty, 'after_qty': m.after_qty, 'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None, 'reference': m.reference or '', 'notes': m.notes or '', } @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def inventory_list(self, **kw): try: Item = request.env['skeen.inventario.item'].sudo() domain = [('active', '=', True)] if kw.get('kind') in ('producto', 'consumible'): domain.append(('kind', '=', kw['kind'])) if kw.get('level') in ('out', 'critical', 'low', 'optimal'): domain.append(('stock_level', '=', kw['level'])) if kw.get('search'): s = kw['search'] domain += ['|', '|', ('name', 'ilike', s), ('sku', 'ilike', s), ('category', 'ilike', s)] items = Item.search(domain, order='name asc', limit=500) # Resumen (mismo filtro de kind, sin level/search) sum_domain = [('active', '=', True)] if kw.get('kind') in ('producto', 'consumible'): sum_domain.append(('kind', '=', kw['kind'])) all_items = Item.search(sum_domain) levels = {'out': 0, 'critical': 0, 'low': 0, 'optimal': 0} total_value = 0.0 for a in all_items: levels[a.stock_level] = levels.get(a.stock_level, 0) + 1 total_value += a.inventory_value or 0.0 return json_response({ 'status': 'success', 'items': [self._item_to_dict(i) for i in items], 'summary': {'levels': levels, 'total_value': total_value, 'count': len(all_items)}, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') def inventory_create(self, **kw): try: data = _parse_json_body() if not data.get('name'): return json_response({'status': 'error', 'message': 'name es obligatorio'}, 400) vals = { 'name': data.get('name'), 'kind': data.get('kind', 'producto'), 'sku': data.get('sku', ''), 'category': data.get('category', ''), 'unit': data.get('unit', 'pieza'), 'qty': float(data.get('qty', 0) or 0), 'qty_optimal': float(data.get('qty_optimal', 0) or 0), 'qty_min': float(data.get('qty_min', 0) or 0), 'cost': float(data.get('cost', 0) or 0), 'expiry_date': data.get('expiry_date') or False, 'notes': data.get('notes', ''), } item = request.env['skeen.inventario.item'].sudo().create(vals) return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') def inventory_update(self, item_id, **kw): try: item = request.env['skeen.inventario.item'].sudo().browse(item_id) if not item.exists(): return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404) data = _parse_json_body() allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min', 'cost', 'expiry_date', 'notes', 'active', 'genera_monedero'] vals = {} for k in allowed: if k in data: vals[k] = data[k] for f in ('qty_optimal', 'qty_min', 'cost'): if f in vals: vals[f] = float(vals[f] or 0) item.write(vals) return json_response({'status': 'success', 'item': self._item_to_dict(item)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventory/items//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') 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') def inventario_compra(self, **kw): """Registrar compra de consumible (sube stock; actualiza costo si viene)""" try: data = _parse_json_body() item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0)) if not item.exists(): return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404) qty = float(data.get('qty', 0) or 0) if qty <= 0: return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400) if data.get('cost') is not None and data.get('cost') != '': cost = float(data.get('cost') or 0) if cost < 0: return json_response({'status': 'error', 'message': 'El costo no puede ser negativo'}, 400) item.write({'cost': cost}) move = request.env['skeen.inventario.move'].sudo().create({ 'item_id': item.id, 'type': 'compra', 'qty': qty, 'reference': data.get('reference', ''), 'notes': data.get('notes', ''), }) item.invalidate_recordset() return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventario/alertas', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def inventario_alertas(self, **kw): """Caducidades próximas y sugerencia de compra por bajo stock""" try: Item = request.env['skeen.inventario.item'].sudo() today = date.today() # Caducidades: todo lo que tiene fecha, ordenado por días restantes caducidades = [] for it in Item.search([('active', '=', True), ('expiry_date', '!=', False)], order='expiry_date asc'): dias = (it.expiry_date - today).days caducidades.append({ 'id': it.id, 'name': it.name, 'category': it.category or '', 'qty': it.qty, 'unit': it.unit or 'pieza', 'expiry_date': it.expiry_date.strftime('%Y-%m-%d'), 'dias_restantes': dias, 'vencido': dias < 0, }) # Sugerencia de compra: bajo mínimo (o sin mínimo y sin existencia) sugerencia = [] for it in Item.search([('active', '=', True)], order='name asc'): qty = it.qty or 0.0 qty_min = it.qty_min or 0.0 bajo = (qty < qty_min) or (qty_min == 0 and qty == 0) if not bajo: continue optimal = it.qty_optimal or 0.0 if optimal > qty: sugerido = optimal - qty else: sugerido = qty_min * 2 if sugerido <= 0: sugerido = max(1.0, qty_min) sugerencia.append({ 'id': it.id, 'name': it.name, 'category': it.category or '', 'unit': it.unit or 'pieza', 'qty': qty, 'qty_min': qty_min, 'qty_optimal': optimal, 'sugerido': sugerido, 'costo_estimado': round(sugerido * (it.cost or 0.0), 2), }) return json_response({ 'status': 'success', 'caducidades': caducidades, 'sugerencia_compra': sugerencia, 'sugerencia_total': { 'items': len(sugerencia), 'costo_total': round(sum(s['costo_estimado'] for s in sugerencia), 2), }, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def inventario_baja(self, **kw): """Registrar baja/merma de consumible (baja stock)""" try: data = _parse_json_body() item = request.env['skeen.inventario.item'].sudo().browse(int(data.get('item_id', 0) or 0)) if not item.exists(): return json_response({'status': 'error', 'message': 'Artículo no encontrado'}, 404) qty = float(data.get('qty', 0) or 0) if qty <= 0: return json_response({'status': 'error', 'message': 'La cantidad debe ser mayor a 0'}, 400) move = request.env['skeen.inventario.move'].sudo().create({ 'item_id': item.id, 'type': 'baja', 'qty': qty, 'reference': data.get('reference', ''), 'notes': data.get('notes', ''), }) item.invalidate_recordset() return json_response({'status': 'success', 'move': self._move_to_dict(move), 'item': self._item_to_dict(item)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # 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') def create_conteo(self, **kw): """Registra un conteo físico y aplica los ajustes por diferencia. Body: {"notes": "...", "lines": [{"item_id": 1, "qty_contado": 7.5}, ...]} Solo se genera movimiento para las líneas con diferencia; el ajuste fija la existencia al valor contado. """ try: data = _parse_json_body() raw_lines = data.get('lines') or [] if not isinstance(raw_lines, list) or not raw_lines: return json_response({'status': 'error', 'message': 'Falta la lista de líneas contadas'}, 400) Item = request.env['skeen.inventario.item'].sudo() line_vals = [] for l in raw_lines: try: item_id = int(l.get('item_id') or 0) qty_contado = float(l.get('qty_contado') or 0) except (TypeError, ValueError): return json_response({'status': 'error', 'message': 'Línea con valores inválidos'}, 400) item = Item.browse(item_id) if not item.exists(): return json_response({'status': 'error', 'message': f'Artículo {item_id} no encontrado'}, 404) line_vals.append((0, 0, { 'item_id': item.id, 'qty_sistema': item.qty, 'qty_contado': qty_contado, 'cost': item.cost, })) operador = getattr(request.frontend_user, 'name', '') or ( getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '') conteo = request.env['skeen.inventario.conteo'].sudo().create({ 'user_name': operador, 'notes': data.get('notes', ''), 'line_ids': line_vals, }) ajustes = conteo.apply_count() return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True), 'ajustes': ajustes}, 201) except Exception as e: request.env.cr.rollback() return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Reporte de comisiones (venta de artículos recetados por médico) # ============================================================ @http.route('/skeen/frontend/v1/reports/commissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') def report_commissions(self, **kw): try: vdomain = [('state', 'in', ['confirmed', 'paid', 'partial'])] if kw.get('start'): vdomain.append(('date', '>=', kw['start'])) if kw.get('end'): vdomain.append(('date', '<=', kw['end'])) ventas = request.env['skeen.venta'].sudo().search(vdomain) venta_ids = ventas.ids rows = [] total_base = 0.0 total_commission = 0.0 if venta_ids: line_domain = [('venta_id', 'in', venta_ids), ('is_prescribed', '=', True), ('prescribed_by_id', '!=', False)] grouped = request.env['skeen.venta.line'].sudo().read_group( line_domain, ['prescribed_by_id', 'subtotal:sum'], ['prescribed_by_id'], ) # Ventas distintas por médico ventas_por_doc = {} for r in request.env['skeen.venta.line'].sudo().search_read(line_domain, ['prescribed_by_id', 'venta_id']): did = r['prescribed_by_id'][0] ventas_por_doc.setdefault(did, set()).add(r['venta_id'][0]) Emp = request.env['hr.employee'].sudo() for g in grouped: did = g['prescribed_by_id'][0] emp = Emp.browse(did) base = g['subtotal'] or 0.0 pct = emp.commission_pct or 0.0 commission = base * pct / 100.0 total_base += base total_commission += commission rows.append({ 'doctor_id': emp.id, 'doctor': emp.name, 'job_title': emp.job_title or '', 'commission_pct': pct, 'base': base, 'commission': commission, 'items': g.get('prescribed_by_id_count', 0), 'sales': len(ventas_por_doc.get(did, set())), }) rows.sort(key=lambda r: r['commission'], reverse=True) return json_response({ 'status': 'success', 'start': kw.get('start'), 'end': kw.get('end'), 'total_base': total_base, 'total_commission': total_commission, 'commissions': rows, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # Importar fields para fechas from odoo import fields