bloque 4: campanas por segmentos con medicion (modulo skeen_campanas: segmentos reutilizables con conteo verificado, campanas por WhatsApp y correo con programacion, prueba previa, limite por minuto, pausa por errores, consentimiento por canal y atribucion de citas) + encuesta al salir con NPS (campo propio, envio automatico y recordatorio, reporte con tendencia y desglose); apagado por defecto

This commit is contained in:
2026-10-12 02:34:34 +00:00
parent 705b5a2842
commit a6d6df4ebd
37 changed files with 4619 additions and 44 deletions

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Campañas y Encuestas',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Campañas por segmentos con medición y encuestas de salida (NPS)',
'description': """
Dos piezas APAGADAS por defecto (`campanas.activo=false` y
`encuestas.activo=false`):
1. Campañas por segmentos: un constructor de criterios reutilizable
sobre la base de pacientes, campañas por WhatsApp/correo con
plantilla, programación (única o recurrente), envío de prueba y
medición por campaña (alcanzados, enviados, respuestas, citas
generadas en una ventana y su valor).
2. Encuestas de salida y satisfacción (NPS): envío automático tras
completar una cita (con recordatorio opcional y opción de solo la
primera visita) y reporte de satisfacción con tendencia y desglose.
Todo el envío pasa por el motor de notificaciones existente
(`skeen.notificacion.service`), respeta el modo de prueba y queda en la
bitácora con su campaña o encuesta de origen. Un paciente sin
consentimiento de marketing nunca recibe una campaña.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': [
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_visitas',
'skeen_ventas', 'skeen_membresias', 'skeen_whatsapp', 'skeen_formularios',
],
'data': [
'security/ir.model.access.csv',
'data/campanas_cron.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import campanas
from . import encuestas

View File

@@ -0,0 +1,576 @@
# -*- coding: utf-8 -*-
"""API autenticada de campañas y segmentos.
Rutas bajo `/skeen/frontend/v1/campanas/*`. Mismo guard doble que el resto del
frontend: `@require_role` para el rango del rol y `@require_perm` para la acción
concreta (`campanas.ver|gestionar|enviar|medir`).
El módulo nace APAGADO (`campanas.activo=false`): las acciones que envían
mensajes responden con un aviso claro y no tocan nada. Leer y administrar
segmentos/campañas sigue disponible para poder configurarlas.
"""
import json
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, current_user, require_role, require_perm,
)
from odoo.addons.skeen_citas.clinic_settings import get_campanas
from ..models.segmento import normalizar_criterios, validar_criterios
PREFIX = '/skeen/frontend/v1/campanas'
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
def _error(message, status=400):
return _json({'status': 'error', 'message': message}, status)
def _parse_dt(valor):
"""Acepta 'YYYY-MM-DD HH:MM', 'YYYY-MM-DDTHH:MM' o vacío."""
texto = str(valor or '').strip().replace('T', ' ')
if not texto:
return False
if len(texto) == 10:
texto += ' 00:00:00'
try:
return fields.Datetime.to_datetime(texto)
except Exception:
raise ValueError('La fecha no es válida (usa AAAA-MM-DD HH:MM)')
def _seg_dict(seg):
return {
'id': seg.id,
'name': seg.name,
'descripcion': seg.descripcion or '',
'activo': seg.activo,
'criterios': seg.criterios_dict(),
'ultimo_conteo': seg.ultimo_conteo,
'muestra': seg.muestra_lista(),
'preview_at': seg.preview_at.strftime('%Y-%m-%d %H:%M') if seg.preview_at else None,
'creado_por': seg.creado_por or '',
}
def _camp_dict(c):
return {
'id': c.id,
'name': c.name,
'descripcion': c.descripcion or '',
'activo': c.activo,
'segmento_id': c.segmento_id.id if c.segmento_id else None,
'segmento': c.segmento_id.name if c.segmento_id else '',
'canal': c.canal,
'canales': c.canales_lista(),
'plantilla': c.plantilla or '',
'enlace': c.enlace or '',
'programacion': c.programacion,
'fecha_programada': c.fecha_programada.strftime('%Y-%m-%d %H:%M')
if c.fecha_programada else None,
'hora': c.hora or '',
'estado': c.estado,
'ultima_ejecucion': c.ultima_ejecucion.strftime('%Y-%m-%d %H:%M')
if c.ultima_ejecucion else None,
'proxima_ejecucion': c.proxima_ejecucion.strftime('%Y-%m-%d %H:%M')
if c.proxima_ejecucion else None,
'motivo_pausa': c.motivo_pausa or '',
'creado_por': c.creado_por or '',
'notas': c.notas or '',
}
class SkeenCampanasController(http.Controller):
# ------------------------------------------------------------------
# Utilidades
# ------------------------------------------------------------------
def _cfg(self):
return get_campanas(request.env)
def _requiere_activo(self):
if not self._cfg()['activo']:
return _error('El módulo de campañas está apagado. Actívalo en '
'Configuración para enviar.', 400)
return None
def _vals_segmento(self, data, crear):
vals = {}
if 'name' in data or crear:
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('El nombre del segmento es obligatorio')
vals['name'] = name[:120]
if 'descripcion' in data:
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'criterios' in data or crear:
vals['criterios'] = json.dumps(
validar_criterios(data.get('criterios') or {}), ensure_ascii=False)
return vals
def _vals_campana(self, data, crear):
vals = {}
if 'name' in data or crear:
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('El nombre de la campaña es obligatorio')
vals['name'] = name[:200]
if 'descripcion' in data:
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'segmento_id' in data:
sid = data.get('segmento_id')
if sid in (None, '', 0):
vals['segmento_id'] = False
else:
seg = request.env['skeen.segmento'].sudo().browse(int(sid))
if not seg.exists():
raise ValueError('El segmento no existe')
vals['segmento_id'] = seg.id
if 'canal' in data:
canal = str(data.get('canal') or '').strip()
if canal not in ('whatsapp', 'email', 'ambos'):
raise ValueError('El canal debe ser whatsapp, email o ambos')
vals['canal'] = canal
if 'plantilla' in data:
vals['plantilla'] = str(data.get('plantilla') or '')[:2000]
if 'enlace' in data:
vals['enlace'] = str(data.get('enlace') or '').strip()[:500]
if 'programacion' in data or crear:
prog = str(data.get('programacion') or 'una_vez').strip()
if prog not in ('una_vez', 'mensual', 'semanal', 'segmento'):
raise ValueError('Programación no válida')
vals['programacion'] = prog
if 'fecha_programada' in data:
vals['fecha_programada'] = _parse_dt(data.get('fecha_programada'))
if 'hora' in data:
hora = str(data.get('hora') or '').strip()
partes = hora.split(':')
if len(partes) != 2:
raise ValueError('La hora debe tener el formato HH:MM')
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
raise ValueError('La hora debe tener el formato HH:MM')
if not (0 <= h <= 23 and 0 <= m <= 59):
raise ValueError('La hora debe tener el formato HH:MM')
vals['hora'] = '%02d:%02d' % (h, m)
if 'notas' in data:
vals['notas'] = str(data.get('notas') or '')[:2000]
return vals
# ------------------------------------------------------------------
# Segmentos
# ------------------------------------------------------------------
@http.route(PREFIX + '/segmentos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def listar_segmentos(self, **kw):
try:
incluir_inactivos = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
dominio = [] if incluir_inactivos else [('activo', '=', True)]
segmentos = request.env['skeen.segmento'].sudo().search(dominio, order='name, id')
return _json({'status': 'success',
'segmentos': [_seg_dict(s) for s in segmentos],
'total': len(segmentos)})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/preview', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def preview_criterios(self, **kw):
"""Vista previa (conteo + muestra) SIN guardar el segmento."""
try:
data = _body()
resultado = request.env['skeen.segmento'].sudo().previsualizar(
data.get('criterios') or {})
return _json({'status': 'success', **resultado})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
@http.route(PREFIX + '/segmentos', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def crear_segmento(self, **kw):
data = _body()
try:
vals = self._vals_segmento(data, crear=True)
usuario = current_user(request.env)
vals['creado_por'] = usuario.name if usuario else ''
seg = request.env['skeen.segmento'].sudo().create(vals)
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)}, 201)
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def actualizar_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
data = _body()
try:
seg.write(self._vals_segmento(data, crear=False))
if 'criterios' in data:
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def borrar_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
try:
usadas = request.env['skeen.campana'].sudo().search_count(
[('segmento_id', '=', seg.id)])
if usadas:
seg.write({'activo': False})
return _json({'status': 'success', 'desactivado': True,
'message': 'El segmento está en uso: se desactivó '
'en lugar de borrarse.'})
seg.unlink()
return _json({'status': 'success', 'desactivado': False,
'message': 'Segmento eliminado'})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>/preview', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def recalcular_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
try:
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Consentimiento
# ------------------------------------------------------------------
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_consentimiento(self, partner_id, **kw):
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists():
return _error('Paciente no encontrado', 404)
return _json({'status': 'success', 'consentimiento': _consent_dict(partner)})
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def set_consentimiento(self, partner_id, **kw):
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists() or not partner.is_patient:
return _error('Paciente no encontrado', 404)
data = _body()
try:
usuario = current_user(request.env)
request.env['res.partner']._marcar_consentimiento(
partner,
general=data.get('general'),
whatsapp=data.get('whatsapp'),
email=data.get('email'),
origen=(usuario.name if usuario else '') or str(data.get('origen') or ''))
return _json({'status': 'success',
'consentimiento': _consent_dict(partner)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Campañas
# ------------------------------------------------------------------
@http.route(PREFIX, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def listar_campanas(self, **kw):
try:
dominio = []
if kw.get('estado'):
dominio.append(('estado', '=', kw['estado']))
campanas = request.env['skeen.campana'].sudo().search(
dominio, order='create_date desc, id desc', limit=500)
return _json({'status': 'success',
'campanas': [_camp_dict(c) for c in campanas],
'total': len(campanas)})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX, type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def crear_campana(self, **kw):
data = _body()
try:
vals = self._vals_campana(data, crear=True)
usuario = current_user(request.env)
vals['creado_por'] = usuario.name if usuario else ''
campana = request.env['skeen.campana'].sudo().create(vals)
return _json({'status': 'success', 'campana': _camp_dict(campana)}, 201)
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
return _json({'status': 'success', 'campana': _camp_dict(campana)})
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def actualizar_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
data = _body()
try:
campana.write(self._vals_campana(data, crear=False))
return _json({'status': 'success', 'campana': _camp_dict(campana)})
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def borrar_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
campana.write({'activo': False, 'estado': 'cancelada'})
return _json({'status': 'success',
'message': 'La campaña se archivó (cancelada y desactivada).'})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def _accion(self, campana_id, accion, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
if accion == 'lanzar':
if not campana.segmento_id:
return _error('La campaña necesita un segmento para lanzarse')
if not (campana.plantilla or '').strip():
return _error('La campaña necesita un mensaje para lanzarse')
if campana.programacion == 'una_vez' and not campana.fecha_programada:
campana.fecha_programada = fields.Datetime.now()
campana.action_lanzar()
elif accion == 'pausar':
campana.action_pausar()
elif accion == 'reanudar':
campana.action_reanudar()
elif accion == 'cancelar':
campana.action_cancelar()
elif accion == 'borrador':
campana.action_borrador()
elif accion == 'procesar':
servicio = request.env['skeen.campana.service'].sudo()
resultado = servicio.ejecutar()
return _json({'status': 'success', **resultado,
'campana': _camp_dict(campana)})
return _json({'status': 'success', 'campana': _camp_dict(campana)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/<string:accion>', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def accion_campana(self, campana_id, accion, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
if accion not in ('lanzar', 'pausar', 'reanudar', 'cancelar', 'borrador',
'procesar'):
return _error('Acción no válida', 404)
return self._accion(campana_id, accion, **kw)
@http.route(PREFIX + '/procesar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def procesar_campanas(self, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
try:
resultado = request.env['skeen.campana.service'].sudo().ejecutar()
return _json({'status': 'success', **resultado})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/prueba', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def prueba_campana(self, campana_id, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
data = _body()
partner = None
if data.get('partner_id'):
partner = request.env['res.partner'].sudo().browse(int(data['partner_id']))
if not partner.exists() or not partner.is_patient:
return _error('El paciente indicado no existe', 404)
try:
resultado = request.env['skeen.campana.service'].sudo().enviar_prueba(
campana, partner=partner,
telefono=str(data.get('telefono') or '').strip(),
correo=str(data.get('correo') or '').strip())
return _json({'status': 'success' if resultado.get('ok') else 'error',
**resultado}, 200 if resultado.get('ok') else 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Medición
# ------------------------------------------------------------------
@http.route(PREFIX + '/<int:campana_id>/metricas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def metricas_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
servicio = request.env['skeen.campana.service'].sudo()
campana_res = campana.with_context(lang=campana.env.user.lang)
datos = campana_res.metricas(start=kw.get('start'), end=kw.get('end'))
return _json({'status': 'success', 'metricas': datos})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/metricas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def metricas_todas(self, **kw):
try:
servicio = request.env['skeen.campana.service'].sudo()
campanas = request.env['skeen.campana'].sudo().search([], limit=500)
datos = [campana.metricas(start=kw.get('start'), end=kw.get('end'))
for campana in campanas]
total = {
'alcanzados': sum(d['alcanzados'] for d in datos),
'enviados': sum(d['enviados'] for d in datos),
'citas_atribuidas': sum(d['citas_atribuidas'] for d in datos),
'valor_atribuido': round(sum(d['valor_atribuido'] for d in datos), 2),
}
return _json({'status': 'success', 'metricas': datos, 'totales': total})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/envios', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def envios_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
servicio = request.env['skeen.campana.service'].sudo()
dominio = [('campana_id', '=', campana.id)]
if str(kw.get('incluir_pruebas') or '') not in ('1', 'true', 'True'):
dominio.append(('es_prueba', '=', False))
notifs = request.env['skeen.notificacion'].sudo().search(
dominio, order='id desc', limit=500)
return _json({'status': 'success',
'envios': [servicio._notif_dict(n) for n in notifs],
'total': len(notifs)})
except Exception as e:
return _error(str(e), 500)
def _consent_dict(partner):
return {
'partner_id': partner.id,
'name': partner.name,
'general': bool(partner.consentimiento_marketing),
'whatsapp': bool(partner.consentimiento_whatsapp),
'email': bool(partner.consentimiento_email),
'at': partner.consentimiento_at.strftime('%Y-%m-%d %H:%M')
if partner.consentimiento_at else None,
'origen': partner.consentimiento_origen or '',
}

View File

@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""API autenticada de encuestas de satisfacción (NPS).
Rutas bajo `/skeen/frontend/v1/encuestas/*`. Guard doble `@require_role` +
`@require_perm` (`encuestas.ver|gestionar|medir`).
Con `encuestas.activo=false` (default) no se encola ni se envía nada; el
reporte simplemente muestra lo que haya (cero en una instancia recién
instalada).
"""
import json
from datetime import date, timedelta
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, current_user, require_role, require_perm,
)
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
PREFIX = '/skeen/frontend/v1/encuestas'
def _error(message, status=400):
return _json({'status': 'error', 'message': message}, status)
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
def _rango(kw):
hoy = date.today()
start = kw.get('start') or (hoy.replace(day=1)).strftime('%Y-%m-%d')
end = kw.get('end') or hoy.strftime('%Y-%m-%d')
return start, end
def _respuesta_dict(r, sucursales=None):
cita = r.cita_id
sucursal = ''
if cita and cita.branch:
sucursal = (sucursales or {}).get(cita.branch, cita.branch)
return {
'id': r.id,
'name': r.name,
'partner_id': r.partner_id.id,
'patient': r.partner_id.name,
'phone': r.partner_id.whatsapp or r.partner_id.phone or '',
'nps_puntaje': r.nps_puntaje,
'nps_categoria': r.nps_categoria or '',
'comentario': r.nps_comentario or '',
'llenado_at': r.llenado_at.strftime('%Y-%m-%d %H:%M') if r.llenado_at else None,
'cita_id': cita.id if cita else None,
'sucursal': sucursal,
'doctor': cita.doctor_id.name if cita and cita.doctor_id else '',
'servicio': cita.servicio_id.name if cita and cita.servicio_id else '',
}
class SkeenEncuestasController(http.Controller):
def _cfg(self):
return get_encuestas(request.env)
def _plantilla(self):
pid = self._cfg().get('plantilla_id')
if not pid:
return request.env['skeen.formulario.plantilla'].sudo().browse()
p = request.env['skeen.formulario.plantilla'].sudo().browse(int(pid))
return p if p.exists() else request.env['skeen.formulario.plantilla'].sudo().browse()
def _respuestas(self, start, end, solo_nps=False):
plantilla = self._plantilla()
if not plantilla:
return request.env['skeen.formulario.respuesta'].sudo().browse()
dominio = [
('plantilla_id', '=', plantilla.id),
('estado', '=', 'llenado'),
('llenado_at', '>=', '%s 00:00:00' % start),
('llenado_at', '<=', '%s 23:59:59' % end),
]
if solo_nps:
dominio.append(('nps_puntaje', '>=', 0))
return request.env['skeen.formulario.respuesta'].sudo().search(
dominio, order='llenado_at desc, id desc')
def _sucursales(self):
return {s.code: s.name for s in
request.env['skeen.sucursal'].sudo().search([])}
# ------------------------------------------------------------------
# Configuración
# ------------------------------------------------------------------
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.ver')
def config_encuestas(self, **kw):
try:
cfg = self._cfg()
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
[('tipo', '=', 'encuesta')], order='name')
plantilla = self._plantilla()
return _json({
'status': 'success',
'config': cfg,
'plantilla': {
'id': plantilla.id, 'name': plantilla.name,
'activo': plantilla.activo,
} if plantilla else None,
'plantillas': [{
'id': p.id, 'name': p.name, 'activo': p.activo,
'tiene_nps': any(c.get('tipo') == 'nps' for c in p.campos_lista()),
} for p in plantillas],
})
except Exception as e:
return _error(str(e), 500)
# ------------------------------------------------------------------
# Reporte de satisfacción
# ------------------------------------------------------------------
@http.route(PREFIX + '/reporte', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.medir')
def reporte_satisfaccion(self, **kw):
try:
start, end = _rango(kw)
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
respuestas = self._respuestas(start, end, solo_nps=True)
general = Respuesta.nps_de(respuestas)
sucursales = self._sucursales()
# Tendencia por mes (últimos 12 meses, independiente del rango).
hoy = date.today()
tendencia = []
for i in range(11, -1, -1):
m = (hoy.replace(day=1) - timedelta(days=i * 31)).replace(day=1)
fin = (m.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
del_mes = Respuesta.search([
('plantilla_id', '=', self._plantilla().id),
('estado', '=', 'llenado'),
('nps_puntaje', '>=', 0),
('llenado_at', '>=', '%s 00:00:00' % m.strftime('%Y-%m-%d')),
('llenado_at', '<=', '%s 23:59:59' % fin.strftime('%Y-%m-%d')),
])
datos = Respuesta.nps_de(del_mes)
tendencia.append({
'mes': m.strftime('%Y-%m'),
'total': datos['total'],
'nps': datos['nps'],
'promotores': datos['promotores'],
'detractores': datos['detractores'],
})
def desglose(etiqueta):
grupos = {}
for r in respuestas:
valor = etiqueta(r) or 'Sin dato'
grupos.setdefault(valor, Respuesta.browse())
grupos[valor] |= r
salida = []
for valor, resps in grupos.items():
d = Respuesta.nps_de(resps)
salida.append({'nombre': valor, **d})
salida.sort(key=lambda x: (-(x['nps'] if x['nps'] is not None else -999),
-x['total']))
return salida
return _json({
'status': 'success',
'start': start,
'end': end,
'general': general,
'tendencia': tendencia,
'por_sucursal': desglose(lambda r: (r.cita_id.branch
and sucursales.get(r.cita_id.branch))
or (r.cita_id.branch if r.cita_id else '')),
'por_doctor': desglose(
lambda r: r.cita_id.doctor_id.name if r.cita_id and r.cita_id.doctor_id else ''),
'por_servicio': desglose(
lambda r: r.cita_id.servicio_id.name if r.cita_id and r.cita_id.servicio_id else ''),
})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/respuestas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.medir')
def listar_respuestas(self, **kw):
try:
start, end = _rango(kw)
respuestas = self._respuestas(start, end, solo_nps=False)
sucursales = self._sucursales()
return _json({
'status': 'success',
'respuestas': [_respuesta_dict(r, sucursales) for r in respuestas[:500]],
'total': len(respuestas),
})
except Exception as e:
return _error(str(e), 500)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
@http.route(PREFIX + '/procesar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('encuestas.gestionar')
def procesar_encuestas(self, **kw):
if not self._cfg()['activo']:
return _error('Las encuestas de salida están apagadas. Actívalas en '
'Configuración.', 400)
try:
resultado = request.env['skeen.encuesta.service'].sudo().ejecutar()
return _json({'status': 'success', **resultado})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/whatsapp', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('encuestas.medir')
def responder_whatsapp(self, respuesta_id, **kw):
"""Responde por WhatsApp al paciente desde el reporte (vía el motor)."""
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
if not respuesta.exists():
return _error('Respuesta no encontrada', 404)
data = _body()
mensaje = str(data.get('mensaje') or '').strip()
if not mensaje:
return _error('Escribe un mensaje para enviar')
partner = respuesta.partner_id
servicio = request.env['skeen.notificacion.service'].sudo()
try:
import uuid as _uuid
notifs = servicio.encolar(
partner=partner, tipo='manual', canal='whatsapp',
cuando=fields.Datetime.now(), mensaje=mensaje[:1000],
clave_unica='encuesta-wa:r%s:%s' % (respuesta.id, _uuid.uuid4().hex[:10]))
if not notifs:
return _error('No se pudo enviar. Revisa que las notificaciones '
'estén activas y el paciente tenga teléfono.', 400)
cfg = servicio._cfg()
estados = [servicio._intentar(n, cfg) for n in notifs]
return _json({'status': 'success',
'simulado': bool(cfg['modo_prueba']),
'notificaciones': len(notifs)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Motor de campañas. Con `campanas.activo=false` (default) el método
regresa sin tocar nada: cero filas y cero envíos. Cada 5 minutos
encola las campañas vencidas y procesa la cola respetando el límite
de envíos por minuto y la pausa por errores seguidos. -->
<record id="cron_campanas" model="ir.cron">
<field name="name">SKEEN Campañas: encolar y enviar por segmentos</field>
<field name="model_id" ref="model_skeen_campana_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
<!-- Encuestas de salida. Con `encuestas.activo=false` (default) no se
encola ni se envía nada: el cron regresa sin efectos. -->
<record id="cron_encuestas" model="ir.cron">
<field name="name">SKEEN Encuestas: enviar tras la cita y recordar</field>
<field name="model_id" ref="model_skeen_encuesta_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import consentimiento
from . import segmento
from . import campana
from . import encuesta

View File

@@ -0,0 +1,561 @@
# -*- coding: utf-8 -*-
"""Campañas por segmentos con medición.
`campanas.activo=false` (default) apaga TODO: no corre el cron, no se encola ni
se envía nada y las campañas nuevas nacen en borrador. El envío real siempre
pasa por el motor de notificaciones (`skeen.notificacion.service`), que respeta
el modo de prueba y registra cada intento en la bitácora con su campaña.
Dos cuidados propios de WhatsApp:
* límite de ENVÍOS POR MINUTO configurable (`campanas.envios_por_minuto`), y
* PAUSA AUTOMÁTICA si se acumulan errores seguidos
(`campanas.pausar_tras_errores`).
Y una regla dura: un paciente SIN consentimiento de marketing nunca recibe una
campaña, aunque cumpla el criterio del segmento. Los avisos transaccionales
(recordatorio de cita, encuesta de salida) no dependen del consentimiento.
"""
import calendar
import re
from datetime import timedelta
from odoo import api, fields, models
from odoo.addons.skeen_citas.clinic_settings import (
get_campanas, get_notificaciones, get_settings as clinic_get_settings,
)
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo'), ('ambos', 'Ambos')]
PROGRAMACION = [
('una_vez', 'Una sola vez'),
('mensual', 'Mensual'),
('semanal', 'Semanal'),
('segmento', 'Según el segmento (recurrente)'),
]
ESTADOS = [
('borrador', 'Borrador'),
('programada', 'Programada'),
('en_ejecucion', 'En ejecución'),
('pausada', 'Pausada'),
('terminada', 'Terminada'),
('cancelada', 'Cancelada'),
]
# Variables disponibles en la plantilla de una campaña.
VARIABLES = ('nombre', 'servicio', 'sucursal', 'enlace', 'doctor', 'fecha',
'hora', 'telefono')
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
PLANTILLA_MAX = 2000
MIN_SEGUNDOS_TASA = 60
def variables_de(texto):
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
class SkeenNotificacion(models.Model):
_inherit = 'skeen.notificacion'
campana_id = fields.Many2one('skeen.campana', string='Campaña', index=True,
ondelete='set null')
campana_periodo = fields.Char(string='Periodo de campaña', index=True)
class SkeenCampana(models.Model):
_name = 'skeen.campana'
_description = 'Campaña por segmento'
_order = 'create_date desc, id desc'
name = fields.Char(string='Nombre', required=True)
descripcion = fields.Text(string='Descripción')
activo = fields.Boolean(string='Activa', default=True)
segmento_id = fields.Many2one('skeen.segmento', string='Segmento',
ondelete='restrict', index=True)
canal = fields.Selection(CANALES, string='Canal', default='whatsapp', required=True)
plantilla = fields.Text(string='Mensaje', default='')
enlace = fields.Char(string='Enlace para {enlace}')
programacion = fields.Selection(PROGRAMACION, string='Programación',
default='una_vez', required=True)
fecha_programada = fields.Datetime(string='Fecha programada')
hora = fields.Char(string='Hora (HH:MM)', default='10:00')
estado = fields.Selection(ESTADOS, string='Estado', default='borrador',
required=True, index=True)
ultima_ejecucion = fields.Datetime(string='Última ejecución', readonly=True)
proxima_ejecucion = fields.Datetime(string='Próxima ejecución', readonly=True)
ultimo_periodo = fields.Char(string='Último periodo', readonly=True)
motivo_pausa = fields.Text(string='Motivo de pausa', readonly=True)
creado_por = fields.Char(string='Creada por', copy=False)
notas = fields.Text(string='Notas')
def _periodo_actual(self, hoy=None):
"""Clave del periodo vigente (idempotencia de los envíos recurrentes)."""
self.ensure_one()
hoy = hoy or fields.Date.today()
if self.programacion == 'semanal':
semana = hoy.isocalendar()
return '%04d-W%02d' % (semana[0], semana[1])
if self.programacion in ('mensual', 'segmento'):
return '%04d-%02d' % (hoy.year, hoy.month)
return hoy.strftime('%Y-%m-%d')
def _canales(self):
self.ensure_one()
if self.canal == 'ambos':
return ['whatsapp', 'email']
return [self.canal]
def canales_lista(self):
return self._canales()
def _esta_vencida(self, ahora=None):
self.ensure_one()
ahora = ahora or fields.Datetime.now()
if self.programacion == 'una_vez':
if self.ultima_ejecucion:
return False
objetivo = self.proxima_ejecucion or self.fecha_programada
return bool(objetivo and objetivo <= ahora)
if self.proxima_ejecucion:
return self.proxima_ejecucion <= ahora
return True
def _siguiente_ejecucion(self, desde=None):
"""Próxima fecha de disparo de una campaña recurrente."""
self.ensure_one()
desde = desde or fields.Datetime.now()
try:
h, m = [int(x) for x in (self.hora or '10:00').split(':')]
except (ValueError, TypeError):
h, m = 10, 0
base = desde.replace(hour=h, minute=m, second=0, microsecond=0)
if self.programacion == 'semanal':
if base <= desde:
base = base + timedelta(days=7)
return base
# mensual / segmento
if base <= desde:
anio = base.year + (1 if base.month == 12 else 0)
mes = 1 if base.month == 12 else base.month + 1
dia = min(base.day, calendar.monthrange(anio, mes)[1])
base = base.replace(year=anio, month=mes, day=dia)
return base
def action_lanzar(self, inmediato=True):
"""Pasa la campaña a 'programada' y (opcional) la agenda para ya."""
self.ensure_one()
vals = {'estado': 'programada', 'motivo_pausa': False}
if self.programacion == 'una_vez':
vals['proxima_ejecucion'] = self.fecha_programada or fields.Datetime.now()
else:
vals['proxima_ejecucion'] = self._siguiente_ejecucion(
fields.Datetime.now() - timedelta(minutes=1)) if inmediato \
else self._siguiente_ejecucion()
self.write(vals)
return True
def action_pausar(self, motivo=''):
self.write({'estado': 'pausada', 'motivo_pausa': motivo or 'Pausada a mano'})
def action_reanudar(self):
self.write({'estado': 'programada', 'motivo_pausa': False})
def action_cancelar(self):
self.write({'estado': 'cancelada'})
def action_borrador(self):
self.write({'estado': 'borrador'})
def metricas(self, start=None, end=None):
"""Métricas de la campaña (delega en el servicio)."""
self.ensure_one()
return self.env['skeen.campana.service']._metricas(self, start=start, end=end)
class SkeenCampanaService(models.AbstractModel):
"""Motor de campañas: encola, respeta consentimiento y mide."""
_name = 'skeen.campana.service'
_description = 'Servicio de campañas de SKEEN'
@api.model
def _cfg(self):
return get_campanas(self.env)
# ------------------------------------------------------------------
# Render de la plantilla
# ------------------------------------------------------------------
@api.model
def _contexto_paciente(self, partner):
settings = clinic_get_settings(self.env)
ctx = {
'nombre': partner.name or '',
'servicio': '',
'sucursal': '',
'doctor': '',
'fecha': fields.Date.today().strftime('%d/%m/%Y'),
'hora': '',
'telefono': (settings.get('telefono')
or get_notificaciones(self.env).get('whatsapp_numero') or ''),
}
cita = self.env['skeen.cita'].sudo().search([
('partner_id', '=', partner.id), ('state', '=', 'done'),
], order='date desc, id desc', limit=1)
if cita:
ctx['servicio'] = cita.servicio_id.name if cita.servicio_id else ''
ctx['doctor'] = cita.doctor_id.name if cita.doctor_id else ''
ctx['fecha'] = cita.date.strftime('%d/%m/%Y') if cita.date else ctx['fecha']
ctx['hora'] = cita.time_str or ''
if cita.branch:
sucursal = self.env['skeen.sucursal'].sudo().search(
[('code', '=', cita.branch)], limit=1)
ctx['sucursal'] = sucursal.name or cita.branch
if not ctx['sucursal']:
sucursal = self.env['skeen.sucursal'].sudo().search(
[('is_default', '=', True)], limit=1) \
or self.env['skeen.sucursal'].sudo().search([], order='sequence', limit=1)
ctx['sucursal'] = sucursal.name or ''
return ctx
def render(self, campana, partner):
ctx = self._contexto_paciente(partner)
ctx['enlace'] = campana.enlace or ''
texto = str(campana.plantilla or '')
for clave, valor in ctx.items():
texto = texto.replace('{%s}' % clave, str(valor or ''))
return texto
# ------------------------------------------------------------------
# Encolado
# ------------------------------------------------------------------
@api.model
def _encolar_campana(self, campana, periodo=None, ahora=None):
"""Encola la campaña para su segmento vigente. Devuelve el # de creadas."""
ahora = ahora or fields.Datetime.now()
periodo = periodo or campana._periodo_actual()
cfg = self._cfg()
service = self.env['skeen.notificacion.service'].sudo()
segmento = campana.segmento_id
if not segmento:
return 0
pacientes = segmento.pacientes()
creadas = 0
for partner in pacientes:
for canal in campana._canales():
if not service._destino_disponible(partner, canal):
continue
# Consentimiento: un paciente sin autorización nunca recibe
# marketing, aunque cumpla el criterio.
if cfg['exigir_consentimiento'] and not partner._marketing_permitido(canal):
continue
mensaje = self.render(campana, partner)
clave = 'campana:c%s:p%s:%s:%s' % (
campana.id, partner.id, periodo, canal)
notifs = service.encolar(
partner=partner, tipo='campana', canal=canal,
cuando=ahora, mensaje=mensaje,
clave_unica=clave, forzar=True,
vals_extra={'campana_id': campana.id, 'campana_periodo': periodo})
creadas += len(notifs)
return creadas
@api.model
def _campanas_vencidas(self, ahora=None):
ahora = ahora or fields.Datetime.now()
campanas = self.env['skeen.campana'].sudo().search([
('estado', 'in', ('programada', 'en_ejecucion')),
('activo', '=', True),
])
return campanas.filtered(lambda c: c._esta_vencida(ahora))
@api.model
def _procesar_campanas(self, cfg, ahora=None):
"""Encola las campañas vencidas (no manda todavía)."""
ahora = ahora or fields.Datetime.now()
encoladas = 0
for campana in self._campanas_vencidas(ahora):
periodo = campana._periodo_actual()
creadas = self._encolar_campana(campana, periodo=periodo, ahora=ahora)
encoladas += creadas
vals = {'ultima_ejecucion': ahora, 'ultimo_periodo': periodo,
'estado': 'en_ejecucion'}
if campana.programacion != 'una_vez':
vals['proxima_ejecucion'] = campana._siguiente_ejecucion(ahora)
campana.write(vals)
return encoladas
# ------------------------------------------------------------------
# Envío con límite por minuto y pausa por errores
# ------------------------------------------------------------------
@api.model
def _enviadas_ultimo_minuto(self, ahora=None):
ahora = ahora or fields.Datetime.now()
desde = ahora - timedelta(seconds=MIN_SEGUNDOS_TASA)
return self.env['skeen.notificacion'].sudo().search_count([
('tipo', '=', 'campana'),
('estado', 'in', ('enviada', 'simulada')),
('enviada_at', '>=', desde),
])
@api.model
def _procesar_cola(self, cfg, limite=None):
"""Manda la cola de campañas respetando el límite por minuto."""
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
ahora = fields.Datetime.now()
presupuesto = cfg['envios_por_minuto'] - self._enviadas_ultimo_minuto(ahora)
if presupuesto <= 0:
return {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': True}
if limite:
presupuesto = min(presupuesto, limite)
pendientes = self.env['skeen.notificacion'].sudo().search([
('tipo', '=', 'campana'),
('estado', '=', 'pendiente'),
('programada_para', '<=', ahora),
], order='programada_para asc, id asc', limit=presupuesto)
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': False}
seguidos = 0
for notif in pendientes:
# Guarda de consentimiento al momento del envío (pudo revocarse
# entre el encolado y ahora).
partner = notif.partner_id
if cfg['exigir_consentimiento'] and partner \
and not partner._marketing_permitido(notif.canal):
notif.write({'estado': 'cancelada',
'error': 'Sin consentimiento de marketing'})
continue
estado = servicio._intentar(notif, notif_cfg)
if estado == 'enviada':
resultado['enviadas'] += 1
seguidos = 0
elif estado == 'simulada':
resultado['simuladas'] += 1
seguidos = 0
elif estado == 'fallida':
resultado['fallidas'] += 1
seguidos += 1
if seguidos >= cfg['pausar_tras_errores']:
campana = notif.campana_id
if campana:
campana.action_pausar(
'Pausada sola tras %d errores seguidos.' % seguidos)
resultado['pausadas'] += 1
break
else:
seguidos = 0
return resultado
@api.model
def _cerrar_campanas(self):
"""Marca como terminadas las campañas de una sola vez ya sin cola."""
pendientes = self.env['skeen.notificacion'].sudo().search([
('tipo', '=', 'campana'), ('estado', '=', 'pendiente')])
con_cola = set(pendientes.mapped('campana_id').ids)
abiertas = self.env['skeen.campana'].sudo().search([
('estado', '=', 'en_ejecucion'), ('programacion', '=', 'una_vez')])
for campana in abiertas:
if campana.id not in con_cola:
campana.write({'estado': 'terminada'})
# ------------------------------------------------------------------
# Entrada del cron
# ------------------------------------------------------------------
@api.model
def ejecutar(self, limite=None):
cfg = self._cfg()
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'enviadas': 0,
'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': False}
if not cfg['activo']:
return resultado
resultado['encoladas'] = self._procesar_campanas(cfg)
envio = self._procesar_cola(cfg, limite=limite)
resultado.update(envio)
self._cerrar_campanas()
return resultado
@api.model
def _cron_tick(self):
return self.ejecutar()
# ------------------------------------------------------------------
# Envío de prueba
# ------------------------------------------------------------------
@api.model
def enviar_prueba(self, campana, partner=None, telefono='', correo=''):
"""Manda la plantilla a un número/correo propio. Nunca lanza.
Queda registrado en la bitácora marcado como prueba (no cuenta en la
medición). El envío real pasa por el motor de notificaciones.
"""
cfg = self._cfg()
if not cfg['activo']:
return {'ok': False, 'message': 'El módulo de campañas está apagado.'}
if not campana.plantilla or not str(campana.plantilla).strip():
return {'ok': False, 'message': 'La campaña no tiene mensaje todavía.'}
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
canales = campana._canales()
creadas = self.env['skeen.notificacion'].sudo()
for canal in canales:
if canal == 'whatsapp' and not telefono and not partner:
return {'ok': False, 'message': 'Indica un número para la prueba.'}
if canal == 'email' and not correo and not partner:
return {'ok': False, 'message': 'Indica un correo para la prueba.'}
if partner:
mensaje = self.render(campana, partner)
destino = ''
else:
mensaje = (str(campana.plantilla or '')
.replace('{nombre}', 'Prueba')
.replace('{servicio}', 'Servicio de ejemplo')
.replace('{sucursal}', 'Sucursal')
.replace('{doctor}', 'Doctor')
.replace('{fecha}', fields.Date.today().strftime('%d/%m/%Y'))
.replace('{hora}', '10:00')
.replace('{enlace}', campana.enlace or '')
.replace('{telefono}', ''))
destino = telefono if canal == 'whatsapp' else correo
clave = 'campana-prueba:c%s:%s:%s' % (
campana.id, canal, fields.Datetime.now().strftime('%Y%m%d%H%M%S%f'))
notifs = servicio.encolar(
partner=partner, tipo='campana', canal=canal,
cuando=fields.Datetime.now(), mensaje=mensaje, clave_unica=clave,
forzar=True, destino=destino or None,
vals_extra={'campana_id': campana.id, 'es_prueba': True})
for notif in notifs:
servicio._intentar(notif, notif_cfg)
creadas |= notifs
if not creadas:
return {'ok': False, 'message': 'No se pudo encolar la prueba.'}
return {
'ok': True,
'simulado': bool(notif_cfg['modo_prueba']),
'notificaciones': [self._notif_dict(n) for n in creadas],
'message': ('Modo prueba: la campaña quedó registrada como simulada.'
if notif_cfg['modo_prueba'] else 'Prueba enviada.'),
}
# ------------------------------------------------------------------
# Medición
# ------------------------------------------------------------------
@api.model
def _notif_dict(self, n):
return {
'id': n.id,
'partner_id': n.partner_id.id if n.partner_id else None,
'patient': n.partner_id.name if n.partner_id else '',
'canal': n.canal,
'estado': n.estado,
'es_prueba': n.es_prueba,
'destino': n.destino or '',
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
'error': n.error or '',
}
@api.model
def _respuestas_wacrm(self, partners, desde, hasta):
"""Mensajes del paciente (WACRM) en la ventana. None si no hay datos.
Se detectan por teléfono del contacto. Si la instancia no sincroniza
WACRM se devuelve None (el reporte lo muestra como "no disponible")."""
if 'skeen.wacrm.message' not in self.env:
return None
M = self.env['skeen.wacrm.message'].sudo()
if not M.search_count([], limit=1):
return None
total = 0
for partner in partners:
digitos = re.sub(r'\D', '', (partner.whatsapp or partner.phone or ''))
if not digitos:
continue
cola = digitos[-10:]
total += M.search_count([
('sender_type', '=', 'customer'),
('created_at', '>=', desde), ('created_at', '<=', hasta),
('contact_id.phone', 'like', cola),
])
return total
def _metricas(self, campana, start=None, end=None):
"""Métricas de la campaña. `start`/`end` acotan las fechas de envío."""
cfg = self._cfg()
Notif = self.env['skeen.notificacion'].sudo()
dominio = [('campana_id', '=', campana.id), ('es_prueba', '=', False)]
if start:
dominio.append(('enviada_at', '>=', '%s 00:00:00' % start))
if end:
dominio.append(('enviada_at', '<=', '%s 23:59:59' % end))
enviadas = Notif.search(dominio + [('estado', 'in', ('enviada', 'simulada'))])
errores = Notif.search_count(dominio + [('estado', '=', 'error')])
canceladas = Notif.search_count(dominio + [('estado', '=', 'cancelada')])
pendientes = Notif.search_count(dominio + [('estado', '=', 'pendiente')])
pacientes = enviadas.mapped('partner_id')
alcanzados = len(pacientes)
ventana = cfg['ventana_atribucion_dias']
citas_count = 0
citas_valor = 0.0
citas_ids = set()
ventas_ids = set()
for partner in pacientes:
envios = enviadas.filtered(lambda n: n.partner_id.id == partner.id
and n.enviada_at)
if not envios:
continue
desde = min(envios.mapped('enviada_at'))
hasta = desde + timedelta(days=ventana)
citas = self.env['skeen.cita'].sudo().search([
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
('create_date', '>=', desde), ('create_date', '<=', hasta)])
citas_ids |= set(citas.ids)
ventas = self.env['skeen.venta'].sudo().search([
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
('create_date', '>=', desde), ('create_date', '<=', hasta)])
ventas_ids |= set(ventas.ids)
citas_valor += sum(v.total for v in ventas)
citas_count = len(citas_ids)
# Respuestas detectables en WACRM dentro de cada ventana.
respuestas = 0
respuestas_disponible = True
if pacientes:
min_envio = min(enviadas.mapped('enviada_at'))
max_envio = max(enviadas.mapped('enviada_at'))
detectadas = self._respuestas_wacrm(
pacientes, min_envio, max_envio + timedelta(days=ventana))
if detectadas is None:
respuestas_disponible = False
else:
respuestas = detectadas
enviadas_reales = len(enviadas.filtered(lambda n: n.estado == 'enviada'))
simuladas = len(enviadas.filtered(lambda n: n.estado == 'simulada'))
return {
'campana_id': campana.id,
'campana': campana.name,
'estado': campana.estado,
'alcanzados': alcanzados,
'enviados': len(enviadas),
'enviados_reales': enviadas_reales,
'simulados': simuladas,
'errores': errores,
'cancelados': canceladas,
'pendientes': pendientes,
# La entrega real (ACK del proveedor) no está disponible con el
# WACRM actual: se reporta como no disponible en lugar de inventarla.
'entregados': None,
'entregados_disponible': False,
'respuestas': respuestas if respuestas_disponible else None,
'respuestas_disponible': respuestas_disponible,
'citas_atribuidas': citas_count,
'valor_atribuido': round(citas_valor, 2),
'ventana_atribucion_dias': ventana,
'conversion_pct': round((citas_count * 100.0 / alcanzados), 2)
if alcanzados else 0.0,
}

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Consentimiento de marketing del paciente, por canal.
Las campañas (marketing) NUNCA escriben a quien no autorizó recibirlas. Los
avisos transaccionales (recordatorio de cita, confirmación, encuesta de salida)
NO dependen de esto: son parte del servicio que el paciente ya contrató.
El consentimiento es explícito y nace APAGADO: la clínica lo registra cuando el
paciente lo autoriza (por WhatsApp, en recepción, etc.). `consentimiento_marketing`
es el interruptor general; `consentimiento_whatsapp` / `consentimiento_email`
autorizan canal por canal.
"""
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
consentimiento_marketing = fields.Boolean(
string='Acepta marketing', default=False, copy=False,
help='Autorización general para recibir promociones y campañas.')
consentimiento_whatsapp = fields.Boolean(
string='Marketing por WhatsApp', default=False, copy=False)
consentimiento_email = fields.Boolean(
string='Marketing por correo', default=False, copy=False)
consentimiento_at = fields.Datetime(
string='Consentimiento registrado el', copy=False)
consentimiento_origen = fields.Char(
string='Origen del consentimiento', copy=False)
def _marketing_permitido(self, canal):
"""True si el paciente autorizó marketing por `canal` (whatsapp|email).
Exige el interruptor general Y el del canal. Sin autorización explícita
devuelve False: el silencio no es consentimiento.
"""
self.ensure_one()
if not self.consentimiento_marketing:
return False
if canal == 'whatsapp':
return bool(self.consentimiento_whatsapp)
if canal == 'email':
return bool(self.consentimiento_email)
return False
@api.model
def _marcar_consentimiento(self, partner, whatsapp=None, email=None,
general=None, origen=''):
"""Registra el consentimiento de un paciente (update parcial)."""
vals = {}
if general is not None:
vals['consentimiento_marketing'] = bool(general)
if whatsapp is not None:
vals['consentimiento_whatsapp'] = bool(whatsapp)
if email is not None:
vals['consentimiento_email'] = bool(email)
if not vals:
return partner
vals['consentimiento_at'] = fields.Datetime.now()
vals['consentimiento_origen'] = (origen or '')[:120]
partner.write(vals)
return partner

View File

@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""Encuestas de salida y medición de satisfacción (NPS).
Cuando una cita pasa a completada y `encuestas.activo=true`, tras N horas se
envía al paciente la encuesta configurada (por WhatsApp o correo) usando el
motor de notificaciones y la página pública de formularios que ya existe. Hay
recordatorio opcional y una opción de enviarla solo a la primera visita.
La pregunta de recomendación se guarda con un tipo de campo PROPIO (`nps`,
puntaje de 0 a 10) para poder analizarla sin depender del texto. Se calcula de
la forma estándar: promotores 9-10, detractores 0-6 y
NPS = %promotores − %detractores (de −100 a 100).
"""
import json
from datetime import timedelta
from odoo import api, fields, models
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
ENCUESTA_LOOKBACK_DIAS = 30
PROCESAR_POR_DEFECTO = 100
class SkeenFormularioRespuesta(models.Model):
_inherit = 'skeen.formulario.respuesta'
nps_puntaje = fields.Integer(
string='NPS (0-10)', compute='_compute_nps', store=True, index=True,
help='-1 cuando la respuesta no incluye la pregunta de recomendación.')
nps_categoria = fields.Selection([
('promotor', 'Promotor'), ('pasivo', 'Pasivo'), ('detractor', 'Detractor'),
], string='Categoría NPS', compute='_compute_nps', store=True, index=True)
nps_comentario = fields.Text(string='Comentario', compute='_compute_nps', store=True)
@api.depends('respuestas', 'plantilla_id')
def _compute_nps(self):
for r in self:
puntaje = -1
comentario = ''
datos = {}
try:
datos = json.loads(r.respuestas or '{}')
except (ValueError, TypeError):
datos = {}
for campo in r.plantilla_id.campos_lista():
clave = campo.get('clave')
tipo = campo.get('tipo')
valor = datos.get(clave)
if tipo == 'nps':
try:
n = int(valor)
except (TypeError, ValueError):
continue
if 0 <= n <= 10:
puntaje = n
elif tipo == 'texto_largo' and not comentario and valor:
comentario = str(valor)
r.nps_puntaje = puntaje
r.nps_comentario = comentario
if puntaje < 0:
r.nps_categoria = False
elif puntaje >= 9:
r.nps_categoria = 'promotor'
elif puntaje >= 7:
r.nps_categoria = 'pasivo'
else:
r.nps_categoria = 'detractor'
@api.model
def nps_de(self, respuestas):
"""Cálculo estándar del NPS sobre un conjunto de respuestas."""
validas = respuestas.filtered(lambda r: r.nps_puntaje >= 0)
total = len(validas)
if not total:
return {'total': 0, 'promotores': 0, 'pasivos': 0, 'detractores': 0,
'nps': None, 'promotores_pct': 0.0, 'detractores_pct': 0.0,
'pasivos_pct': 0.0}
promotores = len(validas.filtered(lambda r: r.nps_categoria == 'promotor'))
pasivos = len(validas.filtered(lambda r: r.nps_categoria == 'pasivo'))
detractores = len(validas.filtered(lambda r: r.nps_categoria == 'detractor'))
pct_p = promotores * 100.0 / total
pct_d = detractores * 100.0 / total
return {
'total': total,
'promotores': promotores,
'pasivos': pasivos,
'detractores': detractores,
'promotores_pct': round(pct_p, 1),
'detractores_pct': round(pct_d, 1),
'pasivos_pct': round(pasivos * 100.0 / total, 1),
'nps': round(pct_p - pct_d),
}
class SkeenEncuestaService(models.AbstractModel):
"""Envío automático de la encuesta de salida (encolado y procesado)."""
_name = 'skeen.encuesta.service'
_description = 'Servicio de encuestas de satisfacción'
@api.model
def _cfg(self):
return get_encuestas(self.env)
@api.model
def _plantilla(self, cfg):
pid = cfg.get('plantilla_id')
Plantilla = self.env['skeen.formulario.plantilla'].sudo()
if not pid:
return Plantilla.browse()
plantilla = Plantilla.browse(int(pid))
return plantilla if plantilla.exists() and plantilla.activo else Plantilla.browse()
@api.model
def _canales(self, cfg):
if cfg['canal'] == 'ambos':
return ['whatsapp', 'email']
return [cfg['canal']]
@api.model
def _enlace(self, token):
from odoo.addons.skeen_citas.clinic_settings import url_publica
base = url_publica(self.env, '')
ruta = '/formulario/%s' % token
return (base + ruta) if base else ruta
# ------------------------------------------------------------------
# Encolado
# ------------------------------------------------------------------
@api.model
def _encolar_citas(self, cfg, plantilla):
from odoo.addons.skeen_formularios.controllers.comun import firmar_token
ahora = fields.Datetime.now()
limite = ahora - timedelta(hours=cfg['enviar_tras_horas'])
desde = ahora - timedelta(days=ENCUESTA_LOOKBACK_DIAS)
citas = self.env['skeen.cita'].sudo().search([
('state', '=', 'done'),
('datetime_end', '<=', limite),
('datetime_end', '>=', desde),
])
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
creadas = 0
for cita in citas:
partner = cita.partner_id
if not partner:
continue
if cfg['solo_primera_visita']:
hechas = self.env['skeen.cita'].sudo().search_count([
('partner_id', '=', partner.id), ('state', '=', 'done')])
if hechas > 1:
continue
if Respuesta.search([
('cita_id', '=', cita.id), ('plantilla_id', '=', plantilla.id)],
limit=1):
continue
respuesta = Respuesta.create({
'plantilla_id': plantilla.id,
'plantilla_version': plantilla.version,
'partner_id': partner.id,
'cita_id': cita.id,
'estado': 'enviado',
'enviado_at': ahora,
'canal_envio': cfg['canal'] if cfg['canal'] != 'ambos' else 'whatsapp',
'respuestas': '{}',
})
token = firmar_token(self.env, respuesta)
respuesta.write({'token': token})
enlace = self._enlace(token)
mensaje = servicio.render(
notif_cfg.get('plantilla_formulario') or '', cita=cita,
partner=partner, cfg=notif_cfg,
extra={'enlace': enlace, 'formulario': plantilla.name})
for canal in self._canales(cfg):
clave = 'encuesta:c%s:%s' % (cita.id, canal)
creadas += len(servicio.encolar(
cita=cita, tipo='encuesta', canal=canal, cuando=ahora,
mensaje=mensaje, clave_unica=clave, forzar=True,
vals_extra={'destino': False}))
return creadas
@api.model
def _encolar_recordatorios(self, cfg, plantilla):
horas = cfg['recordatorio_horas']
if horas <= 0:
return 0
ahora = fields.Datetime.now()
tope = ahora - timedelta(hours=horas)
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
pendientes = Respuesta.search([
('plantilla_id', '=', plantilla.id),
('estado', '=', 'enviado'),
('enviado_at', '<=', tope),
])
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
creados = 0
for r in pendientes:
enlace = self._enlace(r.token)
mensaje = servicio.render(
notif_cfg.get('plantilla_formulario') or '', cita=r.cita_id or None,
partner=r.partner_id, cfg=notif_cfg,
extra={'enlace': enlace, 'formulario': plantilla.name})
for canal in self._canales(cfg):
clave = 'encuesta-rec:r%s:%s' % (r.id, canal)
creados += len(servicio.encolar(
cita=r.cita_id or None, partner=r.partner_id, tipo='encuesta',
canal=canal, cuando=ahora, mensaje=mensaje,
clave_unica=clave, forzar=True))
return creados
# ------------------------------------------------------------------
# Procesado de la cola de encuestas
# ------------------------------------------------------------------
@api.model
def _procesar(self, cfg, limite=None):
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
ahora = fields.Datetime.now()
pendientes = self.env['skeen.notificacion'].sudo().search([
('tipo', '=', 'encuesta'),
('estado', '=', 'pendiente'),
('programada_para', '<=', ahora),
], order='programada_para asc, id asc', limit=limite or PROCESAR_POR_DEFECTO)
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
for notif in pendientes:
estado = servicio._intentar(notif, notif_cfg)
if estado == 'enviada':
resultado['enviadas'] += 1
elif estado == 'simulada':
resultado['simuladas'] += 1
elif estado == 'fallida':
resultado['fallidas'] += 1
return resultado
@api.model
def ejecutar(self, limite=None):
cfg = self._cfg()
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'recordatorios': 0,
'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
if not cfg['activo']:
return resultado
plantilla = self._plantilla(cfg)
if not plantilla:
return resultado
resultado['encoladas'] = self._encolar_citas(cfg, plantilla)
resultado['recordatorios'] = self._encolar_recordatorios(cfg, plantilla)
resultado.update(self._procesar(cfg, limite))
return resultado
@api.model
def _cron_tick(self):
return self.ejecutar()

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
"""Segmentos de pacientes: constructor de criterios reutilizable.
Un segmento guarda un NOMBRE, unos CRITERIOS (JSON) y una previsualización
(cuántos pacientes y una muestra de 5) que se calcula ANTES de usarlo.
La evaluación corre en el servidor: cada criterio aporta un conjunto de
pacientes que se INTERSECTA con el resultado (y se RESTA cuando el criterio es
negativo, p. ej. "con membresía = no"). Así el conteo del segmento coincide
exactamente con lo que responde la base de datos, sin contar de más.
Criterios soportados (todos opcionales; vacío = no aplica):
ultima_visita_dias última visita registrada hace MÁS de N días
sin_visita_meses sin visitas en los últimos N meses (incluye a quien
nunca ha venido)
cumple_mes cumpleaños en el mes en curso
cumple_proximos_dias cumpleaños en los próximos N días
nivel_codigo nivel de lealtad vigente
gasto_minimo gasto acumulado (ventas no canceladas) >= X
sucursal con al menos una cita en esa sucursal
servicio_id que SÍ ha tomado el servicio (cita completada o venta)
servicio_nunca_id que NUNCA ha tomado ese servicio
con_membresia con (true) / sin (false) membresía activa
con_paquetes con (true) / sin (false) paquetes con sesiones disponibles
con_saldo con (true) / sin (false) saldo pendiente
consentimiento 'con' / 'sin' consentimiento de marketing
"""
import json
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
CRITERIOS_DEFAULT = {
'ultima_visita_dias': 0,
'sin_visita_meses': 0,
'cumple_mes': False,
'cumple_proximos_dias': 0,
'nivel_codigo': '',
'gasto_minimo': 0.0,
'sucursal': '',
'servicio_id': 0,
'servicio_nunca_id': 0,
'con_membresia': None,
'con_paquetes': None,
'con_saldo': None,
'consentimiento': '',
}
DIAS_MAX = 3650
MESES_MAX = 120
GASTO_MAX = 100000000.0
MOSTRAR_MUESTRA = 5
def _int(valor, por_defecto=0):
try:
return int(valor)
except (TypeError, ValueError):
return por_defecto
def _num(valor, por_defecto=0.0):
try:
n = float(valor)
except (TypeError, ValueError):
return por_defecto
if n != n or n in (float('inf'), float('-inf')):
return por_defecto
return n
def normalizar_criterios(raw):
"""Criterios completos, con defaults. Tolerante: nunca lanza."""
out = dict(CRITERIOS_DEFAULT)
if not isinstance(raw, dict):
return out
out['ultima_visita_dias'] = min(max(_int(raw.get('ultima_visita_dias'), 0), 0), DIAS_MAX)
out['sin_visita_meses'] = min(max(_int(raw.get('sin_visita_meses'), 0), 0), MESES_MAX)
out['cumple_mes'] = bool(raw.get('cumple_mes'))
out['cumple_proximos_dias'] = min(max(_int(raw.get('cumple_proximos_dias'), 0), 0), DIAS_MAX)
out['nivel_codigo'] = str(raw.get('nivel_codigo') or '').strip()[:40]
out['gasto_minimo'] = min(max(_num(raw.get('gasto_minimo'), 0.0), 0.0), GASTO_MAX)
out['sucursal'] = str(raw.get('sucursal') or '').strip()[:40]
out['servicio_id'] = max(_int(raw.get('servicio_id'), 0), 0)
out['servicio_nunca_id'] = max(_int(raw.get('servicio_nunca_id'), 0), 0)
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
valor = raw.get(k)
out[k] = bool(valor) if isinstance(valor, bool) else None
cons = str(raw.get('consentimiento') or '').strip().lower()
out['consentimiento'] = cons if cons in ('con', 'sin') else ''
return out
def validar_criterios(raw):
"""Valida los criterios de un payload. Lanza ValueError si algo no cuadra.
Devuelve SIEMPRE el objeto normalizado (los criterios se reemplazan
completos: no hay merge parcial de criterios)."""
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError('Los criterios deben ser un objeto')
for k in ('ultima_visita_dias', 'sin_visita_meses', 'cumple_proximos_dias'):
if k in raw and raw[k] not in (None, ''):
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número de días entero' % k)
if raw[k] < 0:
raise ValueError('El campo %s no puede ser negativo' % k)
for k in ('servicio_id', 'servicio_nunca_id'):
if k in raw and raw[k] not in (None, ''):
if isinstance(raw[k], bool) or not isinstance(raw[k], int) or raw[k] < 0:
raise ValueError('El campo %s debe ser un identificador válido' % k)
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
if k in raw and raw[k] is not None and not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero, falso o nulo' % k)
if 'consentimiento' in raw and raw['consentimiento'] not in (None, '', 'con', 'sin'):
raise ValueError("El consentimiento debe ser 'con' o 'sin'")
if 'cumple_mes' in raw and not isinstance(raw['cumple_mes'], bool):
raise ValueError('cumple_mes debe ser verdadero o falso')
return normalizar_criterios(raw)
class SkeenSegmento(models.Model):
_name = 'skeen.segmento'
_description = 'Segmento de pacientes'
_order = 'name, id'
name = fields.Char(string='Nombre', required=True)
descripcion = fields.Text(string='Descripción')
activo = fields.Boolean(string='Activo', default=True, index=True)
criterios = fields.Text(string='Criterios (JSON)', default='{}')
creado_por = fields.Char(string='Creado por', copy=False)
ultimo_conteo = fields.Integer(string='Pacientes (último cálculo)', default=0)
muestra = fields.Text(string='Muestra (JSON)', default='[]')
preview_at = fields.Datetime(string='Calculado el')
_sql_constraints = [
('name_uniq', 'unique(name)', 'Ya existe un segmento con ese nombre.'),
]
# ------------------------------------------------------------------
# Serialización
# ------------------------------------------------------------------
def criterios_dict(self):
self.ensure_one()
try:
data = json.loads(self.criterios or '{}')
except (ValueError, TypeError):
return dict(CRITERIOS_DEFAULT)
return normalizar_criterios(data)
def muestra_lista(self):
self.ensure_one()
try:
data = json.loads(self.muestra or '[]')
except (ValueError, TypeError):
return []
return data if isinstance(data, list) else []
# ------------------------------------------------------------------
# Evaluación
# ------------------------------------------------------------------
@api.model
def _universo(self):
return set(self.env['res.partner'].sudo().search(
[('is_patient', '=', True)]).ids)
@api.model
def _ids_ultima_visita(self, dias):
corte = fields.Date.today() - timedelta(days=dias)
return set(self.env['res.partner'].sudo().search([
('is_patient', '=', True),
('last_visit', '!=', False),
('last_visit', '<=', corte),
]).ids)
@api.model
def _ids_sin_visita(self, meses):
corte = fields.Date.today() - relativedelta(months=meses)
return set(self.env['res.partner'].sudo().search([
('is_patient', '=', True),
'|', ('last_visit', '=', False), ('last_visit', '<=', corte),
]).ids)
@api.model
def _ids_cumple(self, mes_actual=True, proximos_dias=0):
hoy = fields.Date.today()
if proximos_dias:
limite = hoy + timedelta(days=proximos_dias)
else:
limite = hoy
ids = set()
pacientes = self.env['res.partner'].sudo().search([
('is_patient', '=', True), ('birth_date', '!=', False)])
for p in pacientes:
b = p.birth_date
if mes_actual and not proximos_dias:
if b.month == hoy.month:
ids.add(p.id)
continue
# cumpleaños dentro de los próximos N días (mes-día, con 29-feb)
for yr in (hoy.year, hoy.year + 1):
try:
ocurre = b.replace(year=yr)
except ValueError:
ocurre = b.replace(year=yr, day=28)
if hoy <= ocurre <= limite:
ids.add(p.id)
break
return ids
@api.model
def _ids_nivel(self, codigo):
if not codigo:
return set()
return set(self.env['res.partner'].sudo().search([
('is_patient', '=', True),
('lealtad_nivel_codigo', '=', codigo),
]).ids)
@api.model
def _ids_gasto(self, minimo):
if minimo <= 0:
return set()
grupos = self.env['skeen.venta'].sudo().read_group(
[('state', '!=', 'cancelled'), ('partner_id', '!=', False)],
['partner_id', 'total:sum'], ['partner_id'])
return {g['partner_id'][0] for g in grupos
if (g.get('total') or 0.0) >= minimo}
@api.model
def _ids_sucursal(self, code):
if not code:
return set()
citas = self.env['skeen.cita'].sudo().search([
('branch', '=', code), ('state', '!=', 'cancelled')])
return set(citas.mapped('partner_id').ids)
@api.model
def _ids_servicio(self, servicio_id):
"""Pacientes que SÍ tomaron el servicio (cita completada o venta)."""
if not servicio_id:
return set()
ids = set(self.env['skeen.cita'].sudo().search([
('servicio_id', '=', servicio_id), ('state', '=', 'done'),
]).mapped('partner_id').ids)
ventas = self.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'), ('line_ids.service_id', '=', servicio_id)])
ids |= set(ventas.mapped('partner_id').ids)
return ids
@api.model
def _ids_membresia_activa(self):
Model = self.env.get('skeen.membresia')
if Model is None:
return set()
return set(Model.sudo().search([('estado', '=', 'activa')]).mapped('partner_id').ids)
@api.model
def _ids_paquetes_disponibles(self):
compras = self.env['skeen.paquete.compra'].sudo().search([
('state', '=', 'active')])
return set(compras.filtered(
lambda c: (c.sessions_remaining or 0) > 0).mapped('partner_id').ids)
@api.model
def _ids_saldo_pendiente(self):
ventas = self.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'), ('amount_due', '>', 0)])
return set(ventas.mapped('partner_id').ids)
@api.model
def _ids_consentimiento(self):
return set(self.env['res.partner'].sudo().search([
('is_patient', '=', True),
('consentimiento_marketing', '=', True),
]).ids)
@api.model
def _evaluar(self, criterios):
"""Conjunto de ids de pacientes que cumplen los criterios."""
c = normalizar_criterios(criterios)
resultado = self._universo()
if not resultado:
return resultado
if c['ultima_visita_dias'] > 0:
resultado &= self._ids_ultima_visita(c['ultima_visita_dias'])
if c['sin_visita_meses'] > 0:
resultado &= self._ids_sin_visita(c['sin_visita_meses'])
if c['cumple_mes']:
resultado &= self._ids_cumple(mes_actual=True)
if c['cumple_proximos_dias'] > 0:
resultado &= self._ids_cumple(proximos_dias=c['cumple_proximos_dias'])
if c['nivel_codigo']:
resultado &= self._ids_nivel(c['nivel_codigo'])
if c['gasto_minimo'] > 0:
resultado &= self._ids_gasto(c['gasto_minimo'])
if c['sucursal']:
resultado &= self._ids_sucursal(c['sucursal'])
if c['servicio_id']:
resultado &= self._ids_servicio(c['servicio_id'])
if c['servicio_nunca_id']:
resultado -= self._ids_servicio(c['servicio_nunca_id'])
if c['con_membresia'] is not None:
con = self._ids_membresia_activa()
resultado = (resultado & con) if c['con_membresia'] else (resultado - con)
if c['con_paquetes'] is not None:
con = self._ids_paquetes_disponibles()
resultado = (resultado & con) if c['con_paquetes'] else (resultado - con)
if c['con_saldo'] is not None:
con = self._ids_saldo_pendiente()
resultado = (resultado & con) if c['con_saldo'] else (resultado - con)
if c['consentimiento']:
con = self._ids_consentimiento()
resultado = (resultado & con) if c['consentimiento'] == 'con' \
else (resultado - con)
return resultado
@api.model
def _muestra_de(self, ids):
"""Muestra de hasta 5 pacientes con datos mínimos para la vista previa."""
if not ids:
return []
partners = self.env['res.partner'].sudo().browse(sorted(ids)[:MOSTRAR_MUESTRA])
return [{
'id': p.id,
'name': p.name,
'phone': p.phone or p.whatsapp or '',
'email': p.email or '',
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
'nivel': p.lealtad_nivel_codigo or '',
'consentimiento': bool(p.consentimiento_marketing),
} for p in partners]
@api.model
def previsualizar(self, criterios):
"""Conteo + muestra SIN guardar (vista previa del constructor)."""
ids = self._evaluar(criterios)
return {'total': len(ids), 'muestra': self._muestra_de(ids)}
def guardar_preview(self, ids=None):
"""Recalcula y persiste el conteo y la muestra del segmento."""
for seg in self:
ids = self._evaluar(seg.criterios_dict()) if ids is None else ids
seg.write({
'ultimo_conteo': len(ids),
'muestra': json.dumps(self._muestra_de(ids), ensure_ascii=False),
'preview_at': fields.Datetime.now(),
})
return True
def pacientes(self):
"""Recordset de pacientes del segmento (recalcula al vuelo)."""
self.ensure_one()
ids = self._evaluar(self.criterios_dict())
return self.env['res.partner'].sudo().browse(sorted(ids))

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_segmento,Acceso a Segmentos SKEEN,model_skeen_segmento,base.group_user,1,1,1,1
access_skeen_campana,Acceso a Campañas SKEEN,model_skeen_campana,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_segmento Acceso a Segmentos SKEEN model_skeen_segmento base.group_user 1 1 1 1
3 access_skeen_campana Acceso a Campañas SKEEN model_skeen_campana base.group_user 1 1 1 1

View File

@@ -1535,6 +1535,201 @@ def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
return normalizar_propinas(get_settings(env).get('propinas'))
# ---------------------------------------------------------------------------
# Campañas por segmentos (marketing sobre la base de pacientes).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Campañas no aparece en el menú, no corre ningún cron, no se encola ni se
# envía nada y las tablas propias quedan en cero.
#
# activo: enciende TODO el módulo de campañas
# envios_por_minuto: tope de mensajes que el motor manda por minuto
# pausar_tras_errores: errores seguidos que pausan la campaña sola
# ventana_atribucion_dias: días tras el envío en que una cita se atribuye
# exigir_consentimiento: nunca escribir a quien no autorizó marketing
# hora_envio: hora (HH:MM) de los envíos recurrentes
# ---------------------------------------------------------------------------
CAMPANAS_DEFAULT = {
'activo': False,
'envios_por_minuto': 20,
'pausar_tras_errores': 5,
'ventana_atribucion_dias': 7,
'exigir_consentimiento': True,
'hora_envio': '10:00',
}
CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX = 1, 600
CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX = 1, 100
CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX = 1, 90
def normalizar_campanas(raw):
"""Bloque `campanas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(CAMPANAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'exigir_consentimiento'):
out[k] = _bool(raw.get(k), CAMPANAS_DEFAULT[k])
out['envios_por_minuto'] = min(max(_int(
raw.get('envios_por_minuto'), CAMPANAS_DEFAULT['envios_por_minuto']),
CAMPANAS_ENVIOS_MIN), CAMPANAS_ENVIOS_MAX)
out['pausar_tras_errores'] = min(max(_int(
raw.get('pausar_tras_errores'), CAMPANAS_DEFAULT['pausar_tras_errores']),
CAMPANAS_ERRORES_MIN), CAMPANAS_ERRORES_MAX)
out['ventana_atribucion_dias'] = min(max(_int(
raw.get('ventana_atribucion_dias'), CAMPANAS_DEFAULT['ventana_atribucion_dias']),
CAMPANAS_VENTANA_MIN), CAMPANAS_VENTANA_MAX)
out['hora_envio'] = _hhmm_valida(raw.get('hora_envio'), CAMPANAS_DEFAULT['hora_envio'])
return out
def validar_campanas(raw):
"""Valida el bloque `campanas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de campañas debe ser un objeto')
out = {}
for k in ('activo', 'exigir_consentimiento'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
for k, lo, hi in (('envios_por_minuto', CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX),
('pausar_tras_errores', CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX),
('ventana_atribucion_dias', CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX)):
if k in raw:
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número entero' % k)
if not (lo <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
out[k] = raw[k]
if 'hora_envio' in raw:
partes = str(raw['hora_envio'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de envío debe tener el formato HH:MM')
out['hora_envio'] = _hhmm_valida(raw['hora_envio'])
return out
def get_campanas(env):
"""Bloque de campañas vigente (copia). Apagado por defecto."""
return normalizar_campanas(get_settings(env).get('campanas'))
# ---------------------------------------------------------------------------
# Encuestas de salida y satisfacción (NPS).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — no se encola
# ni se envía ninguna encuesta tras completar una cita y no se genera ninguna
# respuesta nueva.
#
# activo: enciende el envío automático de la encuesta al salir
# enviar_tras_horas: horas después de completarse la cita en que se envía
# plantilla_id: plantilla de formulario (tipo encuesta) a enviar
# canal: whatsapp | email | ambos
# recordatorio_horas: horas para reenviar si no responde (0 = sin recordatorio)
# solo_primera_visita: envía solo a quien tiene una única visita
# agradecimiento: mensaje de gracias que ve el paciente al terminar
# ---------------------------------------------------------------------------
ENCUESTAS_DEFAULT = {
'activo': False,
'enviar_tras_horas': 2,
'plantilla_id': None,
'canal': 'whatsapp',
'recordatorio_horas': 48,
'solo_primera_visita': False,
'agradecimiento': '',
}
ENCUESTAS_CANALES = ('whatsapp', 'email', 'ambos')
ENCUESTAS_HORAS_MAX = 720 # 30 días
ENCUESTAS_RECORDATORIO_MAX = 720
ENCUESTAS_AGRADECIMIENTO_MAX = 500
def normalizar_encuestas(raw):
"""Bloque `encuestas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(ENCUESTAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'solo_primera_visita'):
out[k] = _bool(raw.get(k), ENCUESTAS_DEFAULT[k])
out['enviar_tras_horas'] = min(max(_int(
raw.get('enviar_tras_horas'), ENCUESTAS_DEFAULT['enviar_tras_horas']),
0), ENCUESTAS_HORAS_MAX)
out['recordatorio_horas'] = min(max(_int(
raw.get('recordatorio_horas'), ENCUESTAS_DEFAULT['recordatorio_horas']),
0), ENCUESTAS_RECORDATORIO_MAX)
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in ENCUESTAS_CANALES else ENCUESTAS_DEFAULT['canal']
pid = raw.get('plantilla_id')
if pid in (None, '', False, 0, '0'):
out['plantilla_id'] = None
else:
n = _int(pid, 0)
out['plantilla_id'] = n if n > 0 else None
out['agradecimiento'] = str(raw.get('agradecimiento') or '')[:ENCUESTAS_AGRADECIMIENTO_MAX]
return out
def validar_encuestas(raw):
"""Valida el bloque `encuestas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de encuestas debe ser un objeto')
out = {}
for k in ('activo', 'solo_primera_visita'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
for k, hi in (('enviar_tras_horas', ENCUESTAS_HORAS_MAX),
('recordatorio_horas', ENCUESTAS_RECORDATORIO_MAX)):
if k in raw:
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número entero' % k)
if not (0 <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre 0 y %d' % (k, hi))
out[k] = raw[k]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in ENCUESTAS_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'plantilla_id' in raw:
pid = raw['plantilla_id']
if pid in (None, '', False, 0):
out['plantilla_id'] = None
else:
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
raise ValueError('La plantilla de encuesta debe ser un identificador válido')
out['plantilla_id'] = pid
if 'agradecimiento' in raw:
if not isinstance(raw['agradecimiento'], str):
raise ValueError('El agradecimiento debe ser texto')
if len(raw['agradecimiento']) > ENCUESTAS_AGRADECIMIENTO_MAX:
raise ValueError('El agradecimiento es demasiado largo (máx. %d)'
% ENCUESTAS_AGRADECIMIENTO_MAX)
out['agradecimiento'] = raw['agradecimiento'].strip()
return out
def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -1966,6 +2161,8 @@ def get_settings(env, aplicar_licencia=True):
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -1988,6 +2185,8 @@ def get_settings(env, aplicar_licencia=True):
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -2059,6 +2258,16 @@ def set_settings(env, vals):
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
actual['propinas'] = {**actual['propinas'], **bloque}
if 'campanas' in vals:
bloque = validar_campanas(vals['campanas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['campanas'] = {**actual['campanas'], **bloque}
if 'encuestas' in vals:
bloque = validar_encuestas(vals['encuestas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:

View File

@@ -163,6 +163,12 @@ def limpiar_respuestas(campos, respuestas):
out[clave] = int(valor)
except (TypeError, ValueError):
out[clave] = None
elif tipo == 'nps':
try:
n = int(valor)
out[clave] = n if 0 <= n <= 10 else None
except (TypeError, ValueError):
out[clave] = None
elif tipo in ('firma', 'foto', 'archivo'):
out[clave] = bool(valor)
else:
@@ -209,6 +215,18 @@ def validar_respuestas(plantilla, campos, respuestas, fotos_claves,
except (TypeError, ValueError):
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
continue
if tipo == 'nps':
if valor in (None, ''):
if obligatorio:
errores.append('Falta responder: %s' % etiqueta)
else:
try:
n = int(valor)
if not (0 <= n <= 10):
errores.append('%s debe estar entre 0 y 10' % etiqueta)
except (TypeError, ValueError):
errores.append('%s debe ser un número del 0 al 10' % etiqueta)
continue
if tipo == 'numero':
if valor in (None, ''):
if obligatorio:

View File

@@ -15,7 +15,9 @@ from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
from odoo.addons.skeen_citas.clinic_settings import brand_dict, get_settings
from odoo.addons.skeen_citas.clinic_settings import (
brand_dict, get_encuestas, get_settings,
)
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
from .comun import (
@@ -86,6 +88,17 @@ def _primer_nombre(partner):
return nombre.split(' ')[0] if nombre else ''
def _agradecimiento(env, plantilla):
"""Mensaje de gracias de la encuesta de satisfacción (si es la configurada)."""
try:
cfg = get_encuestas(env)
if cfg.get('plantilla_id') and int(cfg['plantilla_id']) == plantilla.id:
return (cfg.get('agradecimiento') or '').strip()
except Exception:
pass
return ''
class SkeenPublicFormularioController(http.Controller):
# ------------------------------------------------------------------
@@ -118,6 +131,7 @@ class SkeenPublicFormularioController(http.Controller):
} if cita else None,
'estado': respuesta.estado,
'ya_llenado': respuesta.estado == 'llenado',
'agradecimiento': _agradecimiento(request.env, plantilla),
'plantilla': {
'nombre': plantilla.name,
'tipo': plantilla.tipo,

View File

@@ -17,7 +17,7 @@ from odoo import api, fields, models
TIPOS_CAMPO = (
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto',
'multi_seleccion', 'checkbox', 'escala', 'nps', 'firma', 'foto',
'archivo', 'consentimiento',
)
TIPOS_PLANTILLA = (

View File

@@ -48,6 +48,8 @@ TIPOS = [
('cobro_membresia', 'Cobro de membresía'),
('acceso_codigo', 'Código de acceso al portal'),
('acceso_enlace', 'Enlace de acceso al portal'),
('campana', 'Campaña'),
('encuesta', 'Encuesta'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -98,7 +100,7 @@ class SkeenNotificacion(models.Model):
_description = 'Notificación de SKEEN'
_order = 'programada_para desc, id desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
partner_id = fields.Many2one('res.partner', string='Paciente', required=False,
index=True, ondelete='cascade')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='cascade')
@@ -108,6 +110,12 @@ class SkeenNotificacion(models.Model):
default='pendiente', index=True)
asunto = fields.Char(string='Asunto')
mensaje = fields.Text(string='Mensaje')
# Destino alterno (teléfono o correo) para avisos sin paciente, como el
# envío de prueba de una campaña a un número propio. Vacío = usar el del
# paciente.
destino = fields.Char(string='Destino alterno')
es_prueba = fields.Boolean(string='Es prueba', default=False, index=True,
help='Prueba de campaña: no cuenta en la medición.')
programada_para = fields.Datetime(string='Programada para', required=True,
default=fields.Datetime.now, index=True)
enviada_at = fields.Datetime(string='Enviada el')
@@ -241,51 +249,64 @@ class SkeenNotificacionService(models.AbstractModel):
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
partner=None, ventana=None, clave_unica=None, asunto=None,
contexto_extra=None):
contexto_extra=None, mensaje=None, vals_extra=None, forzar=False,
destino=None):
"""Crea las notificaciones pendientes de una cita (una por canal).
Con `activo=False` no hace nada. No duplica: si ya existe la misma
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
recordset de lo creado. `contexto_extra` alimenta variables adicionales
del render (p. ej. `{enlace}` del formulario digital).
`mensaje` permite encolar un texto ya resuelto (campañas y encuestas
traen su propia plantilla) en lugar de la del bloque `notificaciones`.
`vals_extra` añade campos al registro (p. ej. `campana_id`). `forzar=True`
salta el interruptor `notificaciones.activo` para los subsistemas que ya
validaron su propio interruptor (campañas, encuestas). `destino` permite
mandar a un número/correo explícito SIN paciente (envío de prueba).
"""
Notif = self.env['skeen.notificacion'].sudo()
cfg = self._cfg()
if not cfg['activo']:
if not cfg['activo'] and not forzar:
return Notif
partner = partner or (cita.partner_id if cita else None)
if not partner:
if not partner and not destino:
return Notif
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if mensaje is None:
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if asunto is None:
asunto = self._asunto(tipo)
canales = [canal] if canal else self._canales(cfg)
creadas = Notif
for ch in canales:
if not self._destino_disponible(partner, ch):
if not destino and not self._destino_disponible(partner, ch):
continue
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
if clave_unica and len(canales) > 1:
clave = '%s:%s' % (clave_unica, ch)
if Notif.search([('clave_unica', '=', clave)], limit=1):
continue
creadas |= Notif.create({
'partner_id': partner.id,
vals = {
'partner_id': partner.id if partner else False,
'cita_id': cita.id if cita is not None else False,
'tipo': tipo,
'canal': ch,
'estado': 'pendiente',
'asunto': asunto,
'mensaje': mensaje,
'destino': destino or False,
'programada_para': cuando or fields.Datetime.now(),
'clave_unica': clave,
})
}
if vals_extra:
vals.update(vals_extra)
creadas |= Notif.create(vals)
return creadas
@api.model
@@ -476,11 +497,13 @@ class SkeenNotificacionService(models.AbstractModel):
return 'simulada'
try:
destino = notif.destino or None
if notif.canal == 'whatsapp':
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg)
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg,
destino=destino)
else:
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
notif.mensaje, cfg=cfg)
notif.mensaje, cfg=cfg, destino=destino)
except Exception as exc:
intentos = notif.intentos + 1
vals = {'intentos': intentos, 'error': str(exc)[:500]}
@@ -509,17 +532,21 @@ class SkeenNotificacionService(models.AbstractModel):
return (url or '').rstrip('/'), (key or '')
@api.model
def enviar_whatsapp(self, partner, mensaje, cfg=None):
def enviar_whatsapp(self, partner, mensaje, cfg=None, destino=None):
"""Envía un WhatsApp real vía la API v1 del WACRM.
Sin credenciales configuradas lanza un error claro (nunca envía a
ciegas ni revienta el proceso de la cola).
`destino` permite mandar a un número explícito (envío de prueba de una
campaña) sin depender del paciente. Sin credenciales configuradas lanza
un error claro (nunca envía a ciegas ni revienta el proceso de la cola).
"""
url, key = self._wacrm_credenciales()
if not url or not key:
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
'en esta instancia.'))
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if destino:
phone = str(destino).strip()
else:
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if not phone:
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
digits = re.sub(r'\D', '', phone)
@@ -547,12 +574,17 @@ class SkeenNotificacionService(models.AbstractModel):
return bool(self.env['ir.mail_server'].sudo().search_count([]))
@api.model
def enviar_email(self, partner, asunto, mensaje, cfg=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro."""
if not partner:
raise UserError(_('Falta el paciente destinatario.'))
destino = (partner.email or '').strip()
if not destino:
def enviar_email(self, partner, asunto, mensaje, cfg=None, destino=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro.
`destino` permite mandar a un correo explícito (envío de prueba de una
campaña) sin depender del paciente.
"""
if destino:
email = str(destino).strip()
else:
email = (partner.email or '').strip() if partner else ''
if not email:
raise UserError(_('El paciente no tiene correo registrado.'))
if not self._smtp_configurado():
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
@@ -564,7 +596,7 @@ class SkeenNotificacionService(models.AbstractModel):
'subject': asunto or 'Notificación',
'body_html': '<div style="white-space:pre-wrap">%s</div>'
% html.escape(mensaje or ''),
'email_to': destino,
'email_to': email,
'email_from': remitente or None,
})
mail.send()

View File

@@ -101,6 +101,17 @@ PERMISSION_GROUPS = [
('formularios.editar', 'Administrar plantillas de formularios'),
('formularios.enviar', 'Enviar formularios a pacientes'),
]),
('campanas', 'Campañas y segmentos', [
('campanas.ver', 'Ver campañas y segmentos'),
('campanas.gestionar', 'Crear y editar campañas y segmentos'),
('campanas.enviar', 'Lanzar campañas y enviar pruebas'),
('campanas.medir', 'Ver la medición de las campañas'),
]),
('encuestas', 'Encuestas de salida (NPS)', [
('encuestas.ver', 'Ver la configuración de encuestas'),
('encuestas.gestionar', 'Configurar y enviar las encuestas de salida'),
('encuestas.medir', 'Ver el reporte de satisfacción'),
]),
('membresias', 'Membresías y suscripciones', [
('membresias.ver', 'Ver membresías, planes y adeudos'),
('membresias.crear', 'Dar de alta membresías'),
@@ -173,6 +184,8 @@ READ_PERM_KEYS = (
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
'campanas.ver', 'campanas.medir',
'encuestas.ver', 'encuestas.medir',
'membresias.ver',
'compras.ver',
'proveedores.ver',
@@ -274,6 +287,17 @@ DEFAULT_PERM_ROLES = {
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
'formularios.editar': AG,
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
# Campañas por segmentos: ver y medir es de todos; gestionar y lanzar es de
# administración/gerencia y del rol de marketing (su dueño natural).
'campanas.ver': ALL,
'campanas.gestionar': ('admin', 'gerencia', 'marketing'),
'campanas.enviar': ('admin', 'gerencia', 'marketing'),
'campanas.medir': ALL,
# Encuestas de salida (NPS): ver y medir es de todos; configurar y enviar
# es de admin/gerencia.
'encuestas.ver': ALL,
'encuestas.gestionar': AG,
'encuestas.medir': ALL,
# Membresías y suscripciones. Ver es de todos; dar de alta y cobrar es de
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
# gerencia/admin (contra el dinero recurrente de la clínica).

View File

@@ -187,6 +187,11 @@ DEFAULT_MENU_ROLES = {
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
# Campañas y satisfacción: apagadas por defecto (campanas.activo /
# encuestas.activo). Se listan aquí para que canSee las reconozca cuando la
# instancia las encienda.
'/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'],
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
# El chat interno se lista explícitamente para dejar fuera a marketing
@@ -197,7 +202,7 @@ DEFAULT_MENU_ROLES = {
# Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
MARKETING_MENUS = (
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
'/wacrm/messages', '/wacrm/leads',
'/wacrm/messages', '/wacrm/leads', '/campanas',
)
# Roles nuevos que por default ven lo mismo que 'lectura'.