562 lines
24 KiB
Python
562 lines
24 KiB
Python
# -*- 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,
|
|
}
|