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:
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'.
|
||||
|
||||
Reference in New Issue
Block a user