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