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:
4
odoo-addons/skeen_campanas/__init__.py
Normal file
4
odoo-addons/skeen_campanas/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
40
odoo-addons/skeen_campanas/__manifest__.py
Normal file
40
odoo-addons/skeen_campanas/__manifest__.py
Normal 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',
|
||||
}
|
||||
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import campanas
|
||||
from . import encuestas
|
||||
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal file
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal 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 '',
|
||||
}
|
||||
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal file
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal 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)
|
||||
31
odoo-addons/skeen_campanas/data/campanas_cron.xml
Normal file
31
odoo-addons/skeen_campanas/data/campanas_cron.xml
Normal 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>
|
||||
6
odoo-addons/skeen_campanas/models/__init__.py
Normal file
6
odoo-addons/skeen_campanas/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import consentimiento
|
||||
from . import segmento
|
||||
from . import campana
|
||||
from . import encuesta
|
||||
561
odoo-addons/skeen_campanas/models/campana.py
Normal file
561
odoo-addons/skeen_campanas/models/campana.py
Normal 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,
|
||||
}
|
||||
63
odoo-addons/skeen_campanas/models/consentimiento.py
Normal file
63
odoo-addons/skeen_campanas/models/consentimiento.py
Normal 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
|
||||
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal file
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal 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()
|
||||
362
odoo-addons/skeen_campanas/models/segmento.py
Normal file
362
odoo-addons/skeen_campanas/models/segmento.py
Normal 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))
|
||||
3
odoo-addons/skeen_campanas/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_campanas/security/ir.model.access.csv
Normal 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
|
||||
|
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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