diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 1a50508..979f8b1 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -10,16 +10,15 @@ import ( type Contrato struct { gorm.Model - ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"` - Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` - ServicioID uint `json:"servicio_id" gorm:"column:servicio_id"` - Servicio Servicio `json:"servicio" gorm:"foreignKey:ServicioID"` - FechaInicio time.Time `json:"fecha_inicio" gorm:"column:fecha_inicio"` - FechaVencimiento time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"` - PrecioAcordado float64 `json:"precio_acordado" gorm:"column:precio_acordado"` - Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo | vencido | cancelado | renovado - AutoRenovar bool `json:"auto_renovar" gorm:"column:auto_renovar;default:false"` - Notas string `json:"notas" gorm:"column:notas"` + ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"` + Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` + Servicios []Servicio `json:"servicios" gorm:"many2many:contrato_servicios"` + FechaInicio time.Time `json:"fecha_inicio" gorm:"column:fecha_inicio"` + FechaVencimiento time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"` + PrecioAcordado float64 `json:"precio_acordado" gorm:"column:precio_acordado"` + Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo | vencido | cancelado | renovado + AutoRenovar bool `json:"auto_renovar" gorm:"column:auto_renovar;default:false"` + Notas string `json:"notas" gorm:"column:notas"` } func (Contrato) TableName() string { return "contratos" } @@ -28,7 +27,7 @@ func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int6 var items []Contrato var total int64 db := app.Http.Database.DB.Model(&Contrato{}). - Preload("Cliente").Preload("Servicio") + Preload("Cliente").Preload("Servicios") if search != "" { db = db.Joins("JOIN clientes ON clientes.id = contratos.cliente_id"). Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?", @@ -48,7 +47,7 @@ func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int6 func GetContratoByID(id uint) (*Contrato, error) { var item Contrato - if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicio").First(&item, id).Error; err != nil { + if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").First(&item, id).Error; err != nil { return nil, err } return &item, nil @@ -57,7 +56,7 @@ func GetContratoByID(id uint) (*Contrato, error) { // GetContratosByCliente devuelve contratos de un cliente específico func GetContratosByCliente(clienteID uint) ([]Contrato, error) { var items []Contrato - if err := app.Http.Database.DB.Preload("Servicio"). + if err := app.Http.Database.DB.Preload("Servicios"). Where("cliente_id = ?", clienteID). Order("fecha_vencimiento ASC").Find(&items).Error; err != nil { return nil, err @@ -72,7 +71,7 @@ func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) { startOfDay := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC) endOfDay := startOfDay.Add(24 * time.Hour) - if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicio"). + if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios"). Where("estado = 'activo' AND fecha_vencimiento >= ? AND fecha_vencimiento < ?", startOfDay, endOfDay). Find(&items).Error; err != nil { log.Printf("Error getting contratos proximos: %v", err) @@ -81,21 +80,42 @@ func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) { return items, nil } -func CreateContrato(c Contrato) error { - return app.Http.Database.DB.Create(&c).Error +func CreateContrato(c Contrato, servicioIDs []uint) error { + db := app.Http.Database.DB + if err := db.Create(&c).Error; err != nil { + return err + } + if len(servicioIDs) > 0 { + var servicios []Servicio + if err := db.Find(&servicios, servicioIDs).Error; err != nil { + return err + } + return db.Model(&c).Association("Servicios").Replace(servicios) + } + return nil } -func UpdateContrato(c Contrato) error { - return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ +func UpdateContrato(c Contrato, servicioIDs []uint) error { + db := app.Http.Database.DB + if err := db.Model(&Contrato{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ "cliente_id": c.ClienteID, - "servicio_id": c.ServicioID, "fecha_inicio": c.FechaInicio, "fecha_vencimiento": c.FechaVencimiento, "precio_acordado": c.PrecioAcordado, "estado": c.Estado, "auto_renovar": c.AutoRenovar, "notas": c.Notas, - }).Error + }).Error; err != nil { + return err + } + if servicioIDs != nil { + var servicios []Servicio + if err := db.Find(&servicios, servicioIDs).Error; err != nil { + return err + } + return db.Model(&Contrato{Model: gorm.Model{ID: c.ID}}).Association("Servicios").Replace(servicios) + } + return nil } func DeleteContrato(id uint) error { @@ -103,5 +123,6 @@ func DeleteContrato(id uint) error { if err := app.Http.Database.DB.First(&c, id).Error; err != nil { return err } + app.Http.Database.DB.Model(&c).Association("Servicios").Clear() return app.Http.Database.DB.Delete(&c).Error } diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index 0e218b9..cb7b5d3 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -56,12 +56,18 @@ func ProcesarVencimientos() { for _, c := range contratos { switch regla.AplicaA { case "renovable": - if c.Servicio.Tipo == "renovable" { - filtrados = append(filtrados, c) + for _, s := range c.Servicios { + if s.Tipo == "renovable" { + filtrados = append(filtrados, c) + break + } } case "unico": - if c.Servicio.Tipo == "unico" { - filtrados = append(filtrados, c) + for _, s := range c.Servicios { + if s.Tipo == "unico" { + filtrados = append(filtrados, c) + break + } } default: filtrados = append(filtrados, c) diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index 5899aba..b705cad 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -82,6 +82,15 @@ func EnviarCorreoManual(contrato *models.Contrato) error { return err } dias := int(contrato.FechaVencimiento.Sub(time.Now()).Hours() / 24) + var items []ItemServicio + for _, s := range contrato.Servicios { + items = append(items, ItemServicio{ + Nombre: s.Nombre, + Precio: s.Precio, + Moneda: s.Moneda, + FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"), + }) + } datos := DatosPlantilla{ ClienteNombre: contrato.Cliente.Nombre, ClienteEmpresa: contrato.Cliente.Empresa, @@ -89,12 +98,7 @@ func EnviarCorreoManual(contrato *models.Contrato) error { FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"), DiasRestantes: dias, Total: contrato.PrecioAcordado, - Servicios: []ItemServicio{{ - Nombre: contrato.Servicio.Nombre, - Precio: contrato.PrecioAcordado, - Moneda: contrato.Servicio.Moneda, - FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"), - }}, + Servicios: items, } html, err := RenderPlantilla(p, datos) if err != nil { @@ -115,12 +119,14 @@ func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cl var fechaVenc time.Time for _, c := range contratos { - items = append(items, ItemServicio{ - Nombre: c.Servicio.Nombre, - Precio: c.PrecioAcordado, - Moneda: c.Servicio.Moneda, - FechaVenc: c.FechaVencimiento.Format("02/01/2006"), - }) + for _, s := range c.Servicios { + items = append(items, ItemServicio{ + Nombre: s.Nombre, + Precio: s.Precio, + Moneda: s.Moneda, + FechaVenc: c.FechaVencimiento.Format("02/01/2006"), + }) + } total += c.PrecioAcordado if fechaVenc.IsZero() || c.FechaVencimiento.Before(fechaVenc) { fechaVenc = c.FechaVencimiento diff --git a/resources/views/renovaciones/contratos.html b/resources/views/renovaciones/contratos.html index 449268a..31bc504 100644 --- a/resources/views/renovaciones/contratos.html +++ b/resources/views/renovaciones/contratos.html @@ -28,7 +28,7 @@ Cliente - Servicio + Servicios Vencimiento Días Precio @@ -43,14 +43,19 @@
- + + + + - + - - +
Sin servicios disponibles
+
@@ -131,6 +142,9 @@
+

+ Sugerido: +

@@ -188,7 +202,7 @@ document.addEventListener('alpine:init', () => { addModal: false, editModal: false, deleteModal: false, clientes: [], servicios: [], selectedId: null, - form: { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' }, + form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' }, toast: { show: false, msg: '', type: 'ok' }, async init() { @@ -205,16 +219,37 @@ document.addEventListener('alpine:init', () => { }, async loadSelects() { - const [c, s] = await Promise.all([ - axios.get('/app/api/clientes/select'), - axios.get('/app/api/servicios/select'), - ]); - this.clientes = c.data.registros || []; - this.servicios = s.data.registros || []; + try { + const [c, s] = await Promise.all([ + axios.get('/app/api/clientes/select'), + axios.get('/app/api/servicios/select'), + ]); + this.clientes = Array.isArray(c.data) ? c.data : (c.data.registros || []); + this.servicios = Array.isArray(s.data) ? s.data : (s.data.registros || []); + } catch(e) { + this.showToast('Error cargando listas: ' + (e.response?.data?.error || e.message), 'error'); + } + }, + + get precioSugerido() { + return this.servicios + .filter(s => (this.form.servicio_ids || []).includes(s.ID)) + .reduce((acc, s) => acc + (parseFloat(s.precio) || 0), 0); + }, + toggleServicio(id) { + const idx = (this.form.servicio_ids || []).indexOf(id); + if (idx === -1) { + this.form.servicio_ids.push(id); + } else { + this.form.servicio_ids.splice(idx, 1); + } + this.form.precio_acordado = this.precioSugerido; }, openEdit(d) { this.form = { + cliente_id: d.cliente_id, + servicio_ids: (d.servicios || []).map(s => s.ID), fecha_inicio: d.fecha_inicio ? d.fecha_inicio.substring(0,10) : '', fecha_vencimiento: d.fecha_vencimiento ? d.fecha_vencimiento.substring(0,10) : '', precio_acordado: d.precio_acordado, @@ -229,7 +264,7 @@ document.addEventListener('alpine:init', () => { closeModals() { this.addModal = this.editModal = this.deleteModal = false; this.selectedId = null; - this.form = { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' }; + this.form = { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' }; }, async save() { diff --git a/rest/controllers/contrato_controller.go b/rest/controllers/contrato_controller.go index c101032..797191a 100644 --- a/rest/controllers/contrato_controller.go +++ b/rest/controllers/contrato_controller.go @@ -31,8 +31,9 @@ func GetContratos(c *fiber.Ctx) error { // Enriquecer con días restantes type ContratoDTO struct { models.Contrato - DiasRestantes int `json:"dias_restantes"` - Urgencia string `json:"urgencia"` // verde | amarillo | rojo + DiasRestantes int `json:"dias_restantes"` + Urgencia string `json:"urgencia"` // verde | amarillo | rojo + PrecioSugerido float64 `json:"precio_sugerido"` } dtos := make([]ContratoDTO, len(records)) now := time.Now() @@ -44,7 +45,11 @@ func GetContratos(c *fiber.Ctx) error { } else if dias <= 30 { urgencia = "amarillo" } - dtos[i] = ContratoDTO{Contrato: r, DiasRestantes: dias, Urgencia: urgencia} + var sugerido float64 + for _, s := range r.Servicios { + sugerido += s.Precio + } + dtos[i] = ContratoDTO{Contrato: r, DiasRestantes: dias, Urgencia: urgencia, PrecioSugerido: sugerido} } return c.JSON(fiber.Map{ "registros": dtos, @@ -57,13 +62,13 @@ func GetContratos(c *fiber.Ctx) error { func CreateContrato(c *fiber.Ctx) error { type Input struct { - ClienteID uint `json:"cliente_id" form:"cliente_id"` - ServicioID uint `json:"servicio_id" form:"servicio_id"` - FechaInicio string `json:"fecha_inicio" form:"fecha_inicio"` - FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"` - PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"` - AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"` - Notas string `json:"notas" form:"notas"` + ClienteID uint `json:"cliente_id"` + ServicioIDs []uint `json:"servicio_ids"` + FechaInicio string `json:"fecha_inicio"` + FechaVencimiento string `json:"fecha_vencimiento"` + PrecioAcordado float64 `json:"precio_acordado"` + AutoRenovar bool `json:"auto_renovar"` + Notas string `json:"notas"` } var inp Input if err := c.BodyParser(&inp); err != nil { @@ -79,7 +84,6 @@ func CreateContrato(c *fiber.Ctx) error { } m := models.Contrato{ ClienteID: inp.ClienteID, - ServicioID: inp.ServicioID, FechaInicio: fi, FechaVencimiento: fv, PrecioAcordado: inp.PrecioAcordado, @@ -87,7 +91,7 @@ func CreateContrato(c *fiber.Ctx) error { Notas: inp.Notas, Estado: "activo", } - if err := models.CreateContrato(m); err != nil { + if err := models.CreateContrato(m, inp.ServicioIDs); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true}) @@ -99,11 +103,12 @@ func UpdateContrato(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } type Input struct { - Estado string `json:"estado" form:"estado"` - FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"` - PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"` - AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"` - Notas string `json:"notas" form:"notas"` + ServicioIDs []uint `json:"servicio_ids"` + Estado string `json:"estado"` + FechaVencimiento string `json:"fecha_vencimiento"` + PrecioAcordado float64 `json:"precio_acordado"` + AutoRenovar bool `json:"auto_renovar"` + Notas string `json:"notas"` } var inp Input if err := c.BodyParser(&inp); err != nil { @@ -125,7 +130,7 @@ func UpdateContrato(c *fiber.Ctx) error { existing.PrecioAcordado = inp.PrecioAcordado existing.AutoRenovar = inp.AutoRenovar existing.Notas = inp.Notas - if err := models.UpdateContrato(*existing); err != nil { + if err := models.UpdateContrato(*existing, inp.ServicioIDs); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) @@ -140,13 +145,21 @@ func RenovarContrato(c *fiber.Ctx) error { if err != nil { return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) } - // Calcular nueva fecha según periodicidad del servicio + // Usar periodicidad del primer servicio asociado + periodicidad := "" + if len(existing.Servicios) > 0 { + periodicidad = existing.Servicios[0].Periodicidad + } nuevaInicio := existing.FechaVencimiento.AddDate(0, 0, 1) - nuevaVenc := calcularFechaVencimiento(nuevaInicio, existing.Servicio.Periodicidad) + nuevaVenc := calcularFechaVencimiento(nuevaInicio, periodicidad) + // Copiar IDs de servicios del contrato anterior + var servicioIDs []uint + for _, s := range existing.Servicios { + servicioIDs = append(servicioIDs, s.ID) + } nuevo := models.Contrato{ ClienteID: existing.ClienteID, - ServicioID: existing.ServicioID, FechaInicio: nuevaInicio, FechaVencimiento: nuevaVenc, PrecioAcordado: existing.PrecioAcordado, @@ -154,12 +167,12 @@ func RenovarContrato(c *fiber.Ctx) error { Estado: "activo", Notas: "Renovación automática desde contrato #" + strconv.Itoa(int(existing.ID)), } - if err := models.CreateContrato(nuevo); err != nil { + if err := models.CreateContrato(nuevo, servicioIDs); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } // Marcar anterior como renovado existing.Estado = "renovado" - models.UpdateContrato(*existing) + models.UpdateContrato(*existing, nil) return c.JSON(fiber.Map{"message": "Renovado", "ok": true}) }