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