feat: contrato soporta múltiples servicios (many2many) con precio sugerido

This commit is contained in:
Lizandro Guarnizo
2026-04-30 22:15:31 -05:00
parent 1b0374204c
commit e751a9f894
5 changed files with 157 additions and 76 deletions
+41 -20
View File
@@ -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
}
+10 -4
View File
@@ -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)
+18 -12
View File
@@ -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
+52 -17
View File
@@ -28,7 +28,7 @@
<thead class="text-left border-b border-gray-200 bg-gray-50">
<tr>
<th class="py-2 px-3">Cliente</th>
<th class="py-2 px-3">Servicio</th>
<th class="py-2 px-3">Servicios</th>
<th class="py-2 px-3">Vencimiento</th>
<th class="py-2 px-3">Días</th>
<th class="py-2 px-3">Precio</th>
@@ -43,14 +43,19 @@
<div class="font-medium" x-text="d.cliente?.nombre||'—'"></div>
<div class="text-xs text-gray-400" x-text="d.cliente?.empresa||''"></div>
</td>
<td class="py-2 px-3" x-text="d.servicio?.nombre||'—'"></td>
<td class="py-2 px-3">
<template x-for="s in (d.servicios||[])" :key="s.ID">
<span class="inline-block text-xs bg-gray-100 rounded px-1.5 py-0.5 mr-1 mb-0.5" x-text="s.nombre"></span>
</template>
<span x-show="!d.servicios||d.servicios.length===0" class="text-gray-400"></span>
</td>
<td class="py-2 px-3" x-text="fmtDate(d.fecha_vencimiento)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="d.urgencia==='rojo' ? 'bg-red-100 text-red-700' : d.urgencia==='amarillo' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'"
x-text="d.dias_restantes <= 0 ? 'Vencido' : d.dias_restantes+' días'"></span>
</td>
<td class="py-2 px-3" x-text="(d.servicio?.moneda||'') +' '+ Number(d.precio_acordado).toFixed(2)"></td>
<td class="py-2 px-3" x-text="Number(d.precio_acordado).toFixed(2)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="{
@@ -111,14 +116,20 @@
</template>
</select>
</div>
<div class="col-span-2" x-show="!editModal">
<label class="text-xs font-medium text-gray-600">Servicio *</label>
<select x-model="form.servicio_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">Seleccionar...</option>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Servicios <span class="font-normal text-gray-400">(uno o varios)</span></label>
<div class="mt-1 border rounded max-h-36 overflow-y-auto divide-y text-sm">
<template x-for="s in servicios" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" :value="s.ID"
:checked="form.servicio_ids.includes(s.ID)"
@change="toggleServicio(s.ID)" />
<span class="flex-1" x-text="s.nombre"></span>
<span class="text-xs text-gray-400" x-text="s.moneda+' '+Number(s.precio).toFixed(2)"></span>
</label>
</template>
</select>
<div x-show="servicios.length===0" class="text-xs text-gray-400 px-3 py-2">Sin servicios disponibles</div>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Fecha inicio *</label>
@@ -131,6 +142,9 @@
<div>
<label class="text-xs font-medium text-gray-600">Precio acordado</label>
<input x-model="form.precio_acordado" type="number" step="0.01" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
<p class="text-xs text-gray-400 mt-0.5" x-show="precioSugerido > 0">
Sugerido: <span class="font-medium text-gray-600" x-text="precioSugerido.toFixed(2)"></span>
</p>
</div>
<div x-show="editModal">
<label class="text-xs font-medium text-gray-600">Estado</label>
@@ -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() {
+36 -23
View File
@@ -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})
}