diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 5e43834..96e5817 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -88,6 +88,22 @@ func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) { return items, nil } +// GetContratosYaVencidos retorna contratos cuya fecha de vencimiento es hoy o anterior +// y su estado sigue siendo 'activo' (aún no han sido marcados como vencidos) +func GetContratosYaVencidos() ([]Contrato, error) { + var items []Contrato + hoy := time.Now().UTC() + startOfDay := time.Date(hoy.Year(), hoy.Month(), hoy.Day(), 0, 0, 0, 0, time.UTC) + + if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios"). + Where("estado = 'activo' AND fecha_vencimiento < ?", startOfDay). + Find(&items).Error; err != nil { + log.Printf("Error getting contratos vencidos: %v", err) + return nil, err + } + return items, nil +} + // syncContratoServicios sincroniza la tabla join contrato_servicios usando SQL directo // para evitar que GORM intente hacer upsert de los servicios existentes. func syncContratoServicios(contratoID uint, servicioIDs []uint) error { diff --git a/pkg/models/notificacion_regla.go b/pkg/models/notificacion_regla.go index c6b5e71..a90a18f 100644 --- a/pkg/models/notificacion_regla.go +++ b/pkg/models/notificacion_regla.go @@ -8,6 +8,7 @@ import ( type NotificacionRegla struct { gorm.Model Nombre string `json:"nombre" gorm:"column:nombre"` + TipoEvento string `json:"tipo_evento" gorm:"column:tipo_evento;default:'vencimiento_proximo'"` // vencimiento_proximo | ya_vencido | bienvenida | pago_recibido | manual DiasAntes int `json:"dias_antes" gorm:"column:dias_antes"` PlantillaID uint `json:"plantilla_id" gorm:"column:plantilla_id"` Plantilla PlantillaCorreo `json:"plantilla" gorm:"foreignKey:PlantillaID"` @@ -35,6 +36,16 @@ func GetReglasActivas() ([]NotificacionRegla, error) { return items, nil } +func GetReglasByTipoEvento(tipoEvento string) ([]NotificacionRegla, error) { + var items []NotificacionRegla + if err := app.Http.Database.DB.Preload("Plantilla"). + Where("activo = ? AND tipo_evento = ?", true, tipoEvento). + Order("dias_antes DESC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func GetReglaByID(id uint) (*NotificacionRegla, error) { var item NotificacionRegla if err := app.Http.Database.DB.Preload("Plantilla").First(&item, id).Error; err != nil { @@ -50,6 +61,7 @@ func CreateRegla(r NotificacionRegla) error { func UpdateRegla(r NotificacionRegla) error { return app.Http.Database.DB.Model(&r).Updates(map[string]interface{}{ "nombre": r.Nombre, + "tipo_evento": r.TipoEvento, "dias_antes": r.DiasAntes, "plantilla_id": r.PlantillaID, "activo": r.Activo, diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index cb7b5d3..24cac2b 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -13,15 +13,20 @@ var cronScheduler *cron.Cron func IniciarCron() { cronScheduler = cron.New() - // Ejecutar todos los días a las 8:00 AM - _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientos) - if err != nil { - log.Printf("[CRON] Error registrando tarea: %v", err) + // Vencimientos próximos — todos los días a las 8:00 AM + if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil { + log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err) + return + } + + // Contratos ya vencidos — todos los días a las 9:00 AM + if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil { + log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err) return } cronScheduler.Start() - log.Println("[CRON] Scheduler iniciado — verificando vencimientos diariamente a las 8:00 AM") + log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM") } // DetenerCron para graceful shutdown @@ -31,13 +36,19 @@ func DetenerCron() { } } -// ProcesarVencimientos es la función principal del cron +// ProcesarVencimientos mantiene compatibilidad para llamadas manuales func ProcesarVencimientos() { - log.Println("[CRON] Iniciando procesamiento de vencimientos...") + ProcesarVencimientosProximos() + ProcesarYaVencidos() +} - reglas, err := models.GetReglasActivas() +// ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo" +func ProcesarVencimientosProximos() { + log.Println("[CRON] Procesando vencimientos próximos...") + + reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo") if err != nil { - log.Printf("[CRON] Error obteniendo reglas: %v", err) + log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err) return } @@ -47,58 +58,86 @@ func ProcesarVencimientos() { log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err) continue } - if len(contratos) == 0 { - continue - } - - // Filtrar por AplicaA - var filtrados []models.Contrato - for _, c := range contratos { - switch regla.AplicaA { - case "renovable": - for _, s := range c.Servicios { - if s.Tipo == "renovable" { - filtrados = append(filtrados, c) - break - } - } - case "unico": - for _, s := range c.Servicios { - if s.Tipo == "unico" { - filtrados = append(filtrados, c) - break - } - } - default: - filtrados = append(filtrados, c) - } - } - if len(filtrados) == 0 { - continue - } - - // Agrupar contratos por cliente - porCliente := make(map[uint][]models.Contrato) - for _, c := range filtrados { - porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c) - } - - for clienteID, grupoContratos := range porCliente { - // Evitar duplicados: ya enviado hoy para esta regla + cliente - if models.YaEnviadoHoy(clienteID, regla.ID) { - log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID) - continue - } - - cliente := &grupoContratos[0].Cliente - if err := EnviarNotificacionGrupo(®la, cliente, grupoContratos); err != nil { - log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err) - } else { - log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", - clienteID, cliente.Email, len(grupoContratos)) - } - } + procesarContratos(®la, contratos) } - log.Println("[CRON] Procesamiento de vencimientos finalizado") + log.Println("[CRON] Vencimientos próximos finalizado") +} + +// ProcesarYaVencidos procesa reglas de tipo "ya_vencido" +func ProcesarYaVencidos() { + log.Println("[CRON] Procesando contratos ya vencidos...") + + reglas, err := models.GetReglasByTipoEvento("ya_vencido") + if err != nil { + log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err) + return + } + if len(reglas) == 0 { + return + } + + contratos, err := models.GetContratosYaVencidos() + if err != nil { + log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err) + return + } + + for _, regla := range reglas { + procesarContratos(®la, contratos) + } + + log.Println("[CRON] Ya vencidos finalizado") +} + +// procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos +func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) { + if len(contratos) == 0 { + return + } + + // Filtrar por AplicaA + var filtrados []models.Contrato + for _, c := range contratos { + switch regla.AplicaA { + case "renovable": + for _, s := range c.Servicios { + if s.Tipo == "renovable" { + filtrados = append(filtrados, c) + break + } + } + case "unico": + for _, s := range c.Servicios { + if s.Tipo == "unico" { + filtrados = append(filtrados, c) + break + } + } + default: + filtrados = append(filtrados, c) + } + } + if len(filtrados) == 0 { + return + } + + // Agrupar por cliente + porCliente := make(map[uint][]models.Contrato) + for _, c := range filtrados { + porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c) + } + + for clienteID, grupoContratos := range porCliente { + if models.YaEnviadoHoy(clienteID, regla.ID) { + log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID) + continue + } + cliente := &grupoContratos[0].Cliente + if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos); err != nil { + log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err) + } else { + log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos)) + } + } } diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index 5cff912..3d4620d 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -80,11 +80,13 @@ func EnviarCorreoPrueba(email string, p *models.PlantillaCorreo) error { func EnviarCorreoManual(contrato *models.Contrato) error { // Intentar obtener plantilla y pasarela desde la primera regla activa var p *models.PlantillaCorreo + var reglaID uint gateway := "bold" reglas, err := models.GetReglasActivas() if err == nil && len(reglas) > 0 { regla := reglas[0] + reglaID = regla.ID gateway = regla.PasarelaEnlace p, err = models.GetPlantillaByID(regla.PlantillaID) if err != nil { @@ -107,7 +109,7 @@ func EnviarCorreoManual(contrato *models.Contrato) error { // Generar o reutilizar el enlace de pago enlacePago := ObtenerOCrearEnlacePago(contrato, gateway) - dias := int(contrato.FechaVencimiento.Sub(time.Now()).Hours() / 24) + dias := int(time.Until(contrato.FechaVencimiento).Hours() / 24) var items []ItemServicio for _, s := range contrato.Servicios { items = append(items, ItemServicio{ @@ -131,7 +133,34 @@ func EnviarCorreoManual(contrato *models.Contrato) error { if err != nil { return err } - return app.Http.Mail.Send(contrato.Cliente.Email, p.Asunto, html) + + // Crear log previo (pendiente) + idsJSON, _ := json.Marshal([]uint{contrato.ID}) + logEntry := models.NotificacionLog{ + ClienteID: contrato.ClienteID, + ReglaID: reglaID, + ContratosIDs: string(idsJSON), + FechaEnvio: time.Now(), + Estado: "pendiente", + Asunto: p.Asunto, + PreviewHTML: html, + } + savedLog, _ := models.CreateNotificacionLog(logEntry) + + sendErr := app.Http.Mail.Send(contrato.Cliente.Email, p.Asunto, html) + + // Actualizar estado del log + if savedLog != nil { + if sendErr != nil { + savedLog.Estado = "fallido" + savedLog.ErrorMsg = sendErr.Error() + } else { + savedLog.Estado = "enviado" + } + models.UpdateNotificacionLog(*savedLog) + } + + return sendErr } // EnviarNotificacionGrupo envía un correo agrupado para un cliente con múltiples contratos @@ -160,7 +189,7 @@ func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cl } } - dias := int(fechaVenc.Sub(time.Now()).Hours() / 24) + dias := int(time.Until(fechaVenc).Hours() / 24) // Generar o reutilizar el enlace de pago para el primer contrato del grupo enlacePago := "" diff --git a/resources/views/renovaciones/reglas.html b/resources/views/renovaciones/reglas.html index 705e61c..af785d8 100644 --- a/resources/views/renovaciones/reglas.html +++ b/resources/views/renovaciones/reglas.html @@ -17,7 +17,8 @@ Nombre - Días antes + Evento + Días Plantilla Aplica a Pasarela @@ -30,7 +31,19 @@ - + + + + + @@ -62,7 +75,7 @@ - Sin reglas configuradas + Sin reglas configuradas @@ -80,26 +93,43 @@
+ + +

+ Se dispara automáticamente cuando se activa un contrato nuevo. + Se dispara automáticamente cuando se confirma un pago (Bold / dLocal). + Solo se envía desde el historial o de forma programada manualmente. +

+
+
- +
-
+
-

Genera automáticamente {{.EnlacePago}} al enviar

+

Genera automáticamente {{"{{"}.EnlacePago{{"}}"}} en la plantilla

@@ -148,7 +178,7 @@ document.addEventListener('alpine:init', () => { loading: false, datos: [], plantillas: [], addModal: false, editModal: false, deleteModal: false, selectedId: null, - form: { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true }, + form: { nombre:'', tipo_evento:'vencimiento_proximo', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true }, toast: { show:false, msg:'', type:'ok' }, async init() { @@ -166,7 +196,7 @@ document.addEventListener('alpine:init', () => { }, openEdit(d) { - this.form = { nombre: d.nombre, dias_antes: d.dias_antes, plantilla_id: d.plantilla_id, aplica_a: d.aplica_a, pasarela_enlace: d.pasarela_enlace||'bold', activo: d.activo }; + this.form = { nombre: d.nombre, tipo_evento: d.tipo_evento||'vencimiento_proximo', dias_antes: d.dias_antes, plantilla_id: d.plantilla_id, aplica_a: d.aplica_a, pasarela_enlace: d.pasarela_enlace||'bold', activo: d.activo }; this.selectedId = d.ID; this.editModal = true; }, @@ -174,7 +204,7 @@ document.addEventListener('alpine:init', () => { closeModals() { this.addModal = this.editModal = this.deleteModal = false; this.selectedId = null; - this.form = { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true }; + this.form = { nombre:'', tipo_evento:'vencimiento_proximo', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true }; }, async toggleActivo(d) { @@ -189,8 +219,7 @@ document.addEventListener('alpine:init', () => { ...this.form, dias_antes: parseInt(this.form.dias_antes) || 0, plantilla_id: parseInt(this.form.plantilla_id) || 0, - }; - if (this.editModal) { + }; if (this.editModal) { await axios.put(`/app/api/reglas-notificacion/${this.selectedId}`, payload); } else { await axios.post('/app/api/reglas-notificacion', payload); @@ -216,6 +245,17 @@ document.addEventListener('alpine:init', () => { showToast(msg, type='ok') { this.toast = { show:true, msg, type }; setTimeout(() => this.toast.show = false, 3000); + }, + + tipoEventoLabel(tipo) { + const map = { + 'vencimiento_proximo': 'Próximo a vencer', + 'ya_vencido': 'Ya vencido', + 'bienvenida': 'Bienvenida', + 'pago_recibido': 'Pago recibido', + 'manual': 'Manual', + }; + return map[tipo] || tipo; } })); });