# -*- 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, }