feat: automatización de cotizaciones, contratos, actas y cuentas de cobro con IA

Implementa las 4 fases de la especificación de automatización: módulo de
plantillas/tarifas editable por el equipo, generación de PDF (HTML+JS vía
Chrome headless) para cotizaciones/contratos/arquitecturas/cuentas de cobro,
chat propio en el dashboard reutilizando el mismo motor y tools del bot de
Telegram, y nuevas tools del agente para crear estos documentos end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 00:45:58 +00:00
co-authored by Claude Sonnet 5
parent 772a4a5656
commit 516220a8a1
32 changed files with 2272 additions and 132 deletions
@@ -0,0 +1,56 @@
package controllers
import (
"math"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// GetArquitecturas lista los patrones de arquitectura de referencia.
func GetArquitecturas(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
limit, _ := strconv.Atoi(c.Query("limit", "20"))
if page < 1 {
page = 1
}
offset := (page - 1) * limit
records, total, err := models.GetAllArquitecturas(limit, offset, c.Query("search"), c.Query("solo_referencias") == "true")
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"registros": records,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
type crearArquitecturaRequest struct {
Requerimiento string `json:"requerimiento"`
Propuesta string `json:"propuesta"`
Nombre string `json:"nombre"`
ClienteID uint `json:"cliente_id"`
GuardarComoReferencia bool `json:"guardar_como_referencia"`
}
// CreateArquitectura genera el PDF de una propuesta técnica.
// POST /api/v2/arquitecturas
func CreateArquitectura(c *fiber.Ctx) error {
var req crearArquitecturaRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
var clienteID *uint
if req.ClienteID != 0 {
clienteID = &req.ClienteID
}
doc, err := services.GenerarArquitectura(req.Requerimiento, req.Propuesta, req.Nombre, clienteID, req.GuardarComoReferencia, generadoPorFromContext(c))
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc})
}
+74
View File
@@ -0,0 +1,74 @@
package controllers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// AsistenteView renderiza el chat propio dentro del dashboard: mismo motor y
// mismas tools que el bot de Telegram (services.ProcessAgentMessage), solo que
// accesible directamente desde la web sin pasar por Telegram.
func AsistenteView(c *fiber.Ctx) error {
return c.Render("automatizacion/asistente", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
type asistenteChatRequest struct {
Mensaje string `json:"mensaje"`
}
// asistenteChatID deriva un chat_id sintético y estable por usuario del dashboard,
// en un rango que nunca colisiona con IDs reales de chats de Telegram (positivos
// < 10^10 para chats privados, negativos para grupos/canales).
func asistenteChatID(c *fiber.Ctx) int64 {
user, _ := c.Locals("user").(map[string]interface{})
var userID uint
if v, ok := user["ID"]; ok {
switch x := v.(type) {
case uint:
userID = x
case int:
userID = uint(x)
case float64:
userID = uint(x)
}
}
return 9_000_000_000 + int64(userID)
}
// PostAsistenteChat procesa un mensaje del chat propio del dashboard.
// POST /app/api/asistente/chat
func PostAsistenteChat(c *fiber.Ctx) error {
var req asistenteChatRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if req.Mensaje == "" {
return c.Status(400).JSON(fiber.Map{"error": "mensaje requerido"})
}
ai, err := models.GetAgenteBotAiConfig()
if err != nil {
return c.Status(503).JSON(fiber.Map{"error": fmt.Sprintf("Asistente no configurado: %v", err)})
}
respuesta, err := services.ProcessAgentMessage(asistenteChatID(c), req.Mensaje, ai)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "respuesta": respuesta})
}
// DeleteAsistenteHistorial borra el historial de la conversación del usuario actual.
// DELETE /app/api/asistente/historial
func DeleteAsistenteHistorial(c *fiber.Ctx) error {
if err := models.ClearAgentHistory(asistenteChatID(c)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+33 -17
View File
@@ -7,6 +7,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Render de vistas ────────────────────────────────────────────────────────
@@ -408,12 +409,12 @@ func GetCuentasCobro(c *fiber.Ctx) error {
func CreateCuentaCobro(c *fiber.Ctx) error {
type Req struct {
ClienteID uint `json:"cliente_id"`
Fecha string `json:"fecha"`
Descripcion string `json:"descripcion"`
Valor float64 `json:"valor"`
FechaVencimiento string `json:"fecha_vencimiento"`
Notas string `json:"notas"`
ClienteID uint `json:"cliente_id"`
Fecha string `json:"fecha"`
Descripcion string `json:"descripcion"`
Valor float64 `json:"valor"`
FechaVencimiento string `json:"fecha_vencimiento"`
Notas string `json:"notas"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -452,13 +453,13 @@ func UpdateCuentaCobro(c *fiber.Ctx) error {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
type Req struct {
Estado string `json:"estado"`
FechaPago string `json:"fecha_pago"`
TransaccionID *uint `json:"transaccion_id"`
Notas string `json:"notas"`
FechaVencimiento string `json:"fecha_vencimiento"`
Descripcion string `json:"descripcion"`
Valor float64 `json:"valor"`
Estado string `json:"estado"`
FechaPago string `json:"fecha_pago"`
TransaccionID *uint `json:"transaccion_id"`
Notas string `json:"notas"`
FechaVencimiento string `json:"fecha_vencimiento"`
Descripcion string `json:"descripcion"`
Valor float64 `json:"valor"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -564,10 +565,10 @@ func UpdateCuentaPagar(c *fiber.Ctx) error {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
type Req struct {
Estado string `json:"estado"`
FechaPago string `json:"fecha_pago"`
TransaccionID *uint `json:"transaccion_id"`
Notas string `json:"notas"`
Estado string `json:"estado"`
FechaPago string `json:"fecha_pago"`
TransaccionID *uint `json:"transaccion_id"`
Notas string `json:"notas"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -614,6 +615,21 @@ func MarcarCuentaPagarPagada(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
}
// GenerarDocumentoCuentaCobro produce el PDF de solicitud de cuenta de cobro a
// partir de la plantilla activa tipo 'cuenta_cobro'.
// POST /api/v2/contabilidad/cuentas-cobro/:id/generar-documento
func GenerarDocumentoCuentaCobro(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
doc, err := services.GenerarDocumentoCuentaCobro(uint(id), generadoPorFromContext(c))
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc})
}
func DeleteCuentaPagar(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
+16
View File
@@ -462,3 +462,19 @@ func MarcarPagoManual(c *fiber.Ctx) error {
go services.EnviarCorreoConfirmacionPago(uint(id), "manual")
return c.JSON(fiber.Map{"ok": true, "mensaje": "Pago marcado como confirmado"})
}
// GenerarDocumentoContrato produce el PDF del contrato (cláusulas estándar) a partir
// de la plantilla activa tipo 'contrato'. La lógica real vive en
// services.GenerarDocumentoContrato, compartida con la tool del agente.
// POST /api/v2/contratos/:id/generar-documento
func GenerarDocumentoContrato(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
doc, err := services.GenerarDocumentoContrato(uint(id), generadoPorFromContext(c))
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc})
}
+51
View File
@@ -0,0 +1,51 @@
package controllers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
type crearCotizacionRequest struct {
ClienteID uint `json:"cliente_id"`
Alcance string `json:"alcance"`
TipoProyecto string `json:"tipo_proyecto"`
Items []services.ItemCotizacion `json:"items"`
}
// CreateCotizacion genera el PDF de una cotización a partir de la plantilla activa
// tipo 'cotizacion' + los items ya calculados por quien llama (Claude decide los
// valores consultando /api/v2/tarifas, el backend solo arma y suma el documento).
// La lógica real vive en services.CrearCotizacion, compartida con la tool del agente.
// POST /api/v2/cotizaciones
func CreateCotizacion(c *fiber.Ctx) error {
var req crearCotizacionRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
doc, total, err := services.CrearCotizacion(req.ClienteID, req.Alcance, req.TipoProyecto, req.Items, generadoPorFromContext(c))
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{
"ok": true,
"message": fmt.Sprintf("Cotización #%d generada", doc.ID),
"total": total,
"documento": doc,
})
}
// generadoPorFromContext distingue si la petición vino del dashboard (sesión web,
// AuthApi) o de la API admin/agente (AdminApiAuth con ADMIN_API_KEY / bot).
func generadoPorFromContext(c *fiber.Ctx) string {
if c.Get("X-Agent-Channel") == "telegram" {
return "telegram"
}
if c.Get("Authorization") != "" || c.Get("X-API-Key") != "" {
return "claude_api"
}
return "web"
}
@@ -0,0 +1,53 @@
package controllers
import (
"fmt"
"math"
"path/filepath"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// GetDocumentosGenerados lista el historial de documentos producidos por la
// automatización con IA (cotizaciones, contratos, actas, cuentas de cobro),
// sin importar el canal que los generó.
func GetDocumentosGenerados(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
limit, _ := strconv.Atoi(c.Query("limit", "20"))
if page < 1 {
page = 1
}
offset := (page - 1) * limit
clienteID, _ := strconv.ParseUint(c.Query("cliente_id", "0"), 10, 32)
records, total, err := models.GetAllDocumentosGenerados(limit, offset, c.Query("tipo"), uint(clienteID))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"registros": records,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
// DownloadDocumentoGenerado sirve el PDF ya generado.
func DownloadDocumentoGenerado(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
doc, err := models.GetDocumentoGeneradoByID(uint(id))
if err != nil || doc.Archivo == "" {
return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"})
}
clean := filepath.Clean(doc.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
c.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, doc.Nombre))
return c.SendFile(clean)
}
@@ -143,6 +143,7 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "GET", "path": "/api/v2/contratos/:id/historial", "desc": "Historial del contrato"},
{"method": "POST", "path": "/api/v2/contratos/:id/verificar-pago", "desc": "Verificar pago Bold"},
{"method": "POST", "path": "/api/v2/contratos/:id/marcar-pagado", "desc": "Marcar pago manual"},
{"method": "POST", "path": "/api/v2/contratos/:id/generar-documento", "desc": "Genera el PDF del contrato (cláusulas estándar) desde la plantilla activa tipo 'contrato'"},
},
},
{
@@ -290,6 +291,7 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "POST", "path": "/api/v2/contabilidad/cuentas-cobro", "desc": "Crear cuenta por cobrar", "body": "cliente_id, descripcion, valor, fecha, fecha_vencimiento, notas"},
{"method": "PUT", "path": "/api/v2/contabilidad/cuentas-cobro/:id", "desc": "Actualizar cuenta por cobrar"},
{"method": "DELETE", "path": "/api/v2/contabilidad/cuentas-cobro/:id", "desc": "Eliminar cuenta por cobrar"},
{"method": "POST", "path": "/api/v2/contabilidad/cuentas-cobro/:id/generar-documento", "desc": "Genera el PDF de solicitud de cuenta de cobro desde la plantilla activa tipo 'cuenta_cobro'"},
{"method": "GET", "path": "/api/v2/contabilidad/cuentas-pagar", "desc": "Listar cuentas por pagar. Query: ?page=&search=&estado="},
{"method": "POST", "path": "/api/v2/contabilidad/cuentas-pagar", "desc": "Crear cuenta por pagar", "body": "entidad_id, descripcion, valor, fecha, vencimiento, notas"},
{"method": "PUT", "path": "/api/v2/contabilidad/cuentas-pagar/:id", "desc": "Actualizar cuenta por pagar"},
@@ -494,6 +496,45 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "GET", "path": "/api/v2/vcard-api/miniwebs", "desc": "Listar miniwebs"},
},
},
{
"nombre": "Automatización IA: Plantillas de documento",
"endpoints": []fiber.Map{
{"method": "GET", "path": "/api/v2/plantillas-documento", "desc": "Listar plantillas. Query: ?tipo=cotizacion|contrato|acta|cuenta_cobro&search="},
{"method": "GET", "path": "/api/v2/plantillas-documento/:id", "desc": "Detalle de una plantilla"},
{"method": "POST", "path": "/api/v2/plantillas-documento", "desc": "Crear plantilla", "body": "tipo, nombre, contenido_html, version, activa"},
{"method": "PUT", "path": "/api/v2/plantillas-documento/:id", "desc": "Actualizar plantilla"},
{"method": "DELETE", "path": "/api/v2/plantillas-documento/:id", "desc": "Eliminar plantilla"},
},
},
{
"nombre": "Automatización IA: Tarifas",
"endpoints": []fiber.Map{
{"method": "GET", "path": "/api/v2/tarifas", "desc": "Listar tarifas. Query: ?categoria=hora_servicio|licencia|vm_azure|margen|otro&search="},
{"method": "POST", "path": "/api/v2/tarifas", "desc": "Crear tarifa", "body": "categoria, nombre, valor, moneda, unidad, notas"},
{"method": "PUT", "path": "/api/v2/tarifas/:id", "desc": "Actualizar tarifa"},
{"method": "DELETE", "path": "/api/v2/tarifas/:id", "desc": "Eliminar tarifa"},
},
},
{
"nombre": "Automatización IA: Arquitecturas",
"endpoints": []fiber.Map{
{"method": "GET", "path": "/api/v2/arquitecturas", "desc": "Listar patrones de arquitectura de referencia. Query: ?search=&solo_referencias=true"},
{"method": "POST", "path": "/api/v2/arquitecturas", "desc": "Genera el PDF de una propuesta técnica ya redactada", "body": "requerimiento, propuesta, nombre, cliente_id, guardar_como_referencia"},
},
},
{
"nombre": "Automatización IA: Cotizaciones",
"endpoints": []fiber.Map{
{"method": "POST", "path": "/api/v2/cotizaciones", "desc": "Genera el PDF de una cotización desde la plantilla activa. Consultar /api/v2/tarifas antes para calcular los items", "body": "cliente_id, alcance, tipo_proyecto, items: [{descripcion, cantidad, valor_unitario, unidad}]"},
},
},
{
"nombre": "Automatización IA: Historial de documentos generados",
"endpoints": []fiber.Map{
{"method": "GET", "path": "/api/v2/documentos-generados", "desc": "Historial de documentos generados. Query: ?tipo=&cliente_id="},
{"method": "GET", "path": "/api/v2/documentos-generados/:id/download", "desc": "Descargar el PDF generado"},
},
},
},
}
return c.JSON(spec)
@@ -0,0 +1,162 @@
package controllers
import (
"math"
"strconv"
"text/template"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
func PlantillasDocumentoView(c *fiber.Ctx) error {
return c.Render("automatizacion/plantillas_documento", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func TarifasView(c *fiber.Ctx) error {
return c.Render("automatizacion/tarifas", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetPlantillasDocumento(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
limit, _ := strconv.Atoi(c.Query("limit", "20"))
if page < 1 {
page = 1
}
offset := (page - 1) * limit
records, total, err := models.GetAllPlantillasDocumento(limit, offset, c.Query("search"), c.Query("tipo"))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"registros": records,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
func GetPlantillaDocumento(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
item, err := models.GetPlantillaDocumentoByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrada"})
}
return c.JSON(item)
}
func CreatePlantillaDocumento(c *fiber.Ctx) error {
var m models.PlantillaDocumento
if err := c.BodyParser(&m); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if m.ContenidoHTML != "" {
if _, err := template.New("validate").Parse(m.ContenidoHTML); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Sintaxis inválida en el HTML: " + err.Error()})
}
}
if err := models.CreatePlantillaDocumento(m); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"message": "Plantilla creada", "ok": true})
}
func UpdatePlantillaDocumento(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
var updates map[string]interface{}
if err := c.BodyParser(&updates); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if html, ok := updates["contenido_html"].(string); ok && html != "" {
if _, err := template.New("validate").Parse(html); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Sintaxis inválida en el HTML: " + err.Error()})
}
}
if err := models.UpdatePlantillaDocumento(uint(id), updates); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
}
func DeletePlantillaDocumento(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
if err := models.DeletePlantillaDocumento(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
}
// ─── Tarifas ────────────────────────────────────────────────────────────────
func GetTarifas(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
if page < 1 {
page = 1
}
offset := (page - 1) * limit
records, total, err := models.GetAllTarifas(limit, offset, c.Query("search"), c.Query("categoria"))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"registros": records,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
func CreateTarifa(c *fiber.Ctx) error {
var m models.Tarifa
if err := c.BodyParser(&m); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if err := models.CreateTarifa(m); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"message": "Tarifa creada", "ok": true})
}
func UpdateTarifa(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
var updates map[string]interface{}
if err := c.BodyParser(&updates); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if err := models.UpdateTarifa(uint(id), updates); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
}
func DeleteTarifa(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
if err := models.DeleteTarifa(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
}