feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
96cd24f17d
commit
08265510ea
@@ -116,9 +116,9 @@ func CleanCloudflareToken(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Token re-guardado limpio",
|
||||
"chars_before": before,
|
||||
"chars_after": after,
|
||||
"message": "Token re-guardado limpio",
|
||||
"chars_before": before,
|
||||
"chars_after": after,
|
||||
"chars_removed": before - after,
|
||||
})
|
||||
}
|
||||
@@ -244,11 +244,11 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"auth_type": "global_key",
|
||||
"user_email": user.Email,
|
||||
"user_id": user.ID,
|
||||
"token": tokenDiag,
|
||||
"ok": true,
|
||||
"auth_type": "global_key",
|
||||
"user_email": user.Email,
|
||||
"user_id": user.ID,
|
||||
"token": tokenDiag,
|
||||
"permissions_test": fiber.Map{
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
@@ -311,10 +311,15 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
|
||||
"token_prev": preview,
|
||||
"was_dirty": dirty,
|
||||
"detail_error": detailErrMsg,
|
||||
"policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(),
|
||||
"policies": func() interface{} {
|
||||
if detail != nil {
|
||||
return detail.Policies
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
"permissions_test": fiber.Map{
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
|
||||
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
|
||||
},
|
||||
"hint": hint,
|
||||
})
|
||||
@@ -408,7 +413,12 @@ func GetCloudflareSSL(c *fiber.Ctx) error {
|
||||
"source": "universal_ssl",
|
||||
"universal_ssl": fiber.Map{
|
||||
"enabled": universal.Enabled,
|
||||
"note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(),
|
||||
"note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string {
|
||||
if universal.Enabled {
|
||||
return "ACTIVO"
|
||||
}
|
||||
return "INACTIVO"
|
||||
}(),
|
||||
},
|
||||
"packs_error": packsErr.Error(),
|
||||
})
|
||||
|
||||
@@ -67,15 +67,15 @@ func CreateFactura(c *fiber.Ctx) error {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"})
|
||||
}
|
||||
f := &models.Factura{
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
FechaEmision: time.Now(),
|
||||
}
|
||||
if req.FechaEmision != "" {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestOcrConfigHandler(c *fiber.Ctx) error {
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
texto, err := services.ExtraerTextoOCR(data, mimeType)
|
||||
texto, err := services.ExtraerTextoOCR(0, data, mimeType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -597,4 +597,3 @@ func SavePaypalConfigWeb(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"})
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,9 @@ func DownloadEntregable(c *fiber.Ctx) error {
|
||||
|
||||
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
|
||||
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
||||
type Req struct{ Visible bool `json:"visible"` }
|
||||
type Req struct {
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -530,7 +532,9 @@ func GetTickets(c *fiber.Ctx) error {
|
||||
|
||||
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Estado string `json:"estado"` }
|
||||
type Req struct {
|
||||
Estado string `json:"estado"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -543,7 +547,9 @@ func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
||||
|
||||
func AdminResponderTicket(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Contenido string `json:"contenido"` }
|
||||
type Req struct {
|
||||
Contenido string `json:"contenido"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
|
||||
@@ -103,7 +103,6 @@ func RequestPasswordResetPost(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// todo: Generar contrasenas aleatorias /do/generate-password
|
||||
func generatePassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&"
|
||||
|
||||
@@ -17,7 +17,7 @@ func CreateNewRole(c *fiber.Ctx) error {
|
||||
func RemoveRole(c *fiber.Ctx) error {
|
||||
var role models.Role
|
||||
c.BodyParser(&role)
|
||||
|
||||
|
||||
app.Http.Database.
|
||||
Delete(&models.RoleAndPermission{})
|
||||
app.Http.Auth.Enforcer.LoadPolicy()
|
||||
@@ -30,10 +30,10 @@ func AssignRoleToUser(c *fiber.Ctx) error {
|
||||
c.BodyParser(&roleRequest)
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "g",
|
||||
V0: fmt.Sprintf("%d", roleRequest.UserID),
|
||||
V1: roleRequest.Role,
|
||||
V0: fmt.Sprintf("%d", roleRequest.UserID),
|
||||
V1: roleRequest.Role,
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
app.Http.Auth.Enforcer.LoadPolicy()
|
||||
@@ -60,7 +60,7 @@ func ChangeRoleForUser(c *fiber.Ctx) error {
|
||||
var role models.RoleRequest
|
||||
var role1 models.RoleAndPermission
|
||||
c.BodyParser(&role)
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g",V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g", V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
|
||||
if err == nil {
|
||||
role1.V1 = role.Role
|
||||
role1.DeletedAt = gorm.DeletedAt{Valid: false}
|
||||
@@ -77,13 +77,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
|
||||
c.BodyParser(&permission)
|
||||
if permission.Role != "" && permission.Module != "" && permission.Action != "" {
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Module,
|
||||
V2: permission.Action,
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Module,
|
||||
V2: permission.Action,
|
||||
Category: "permission",
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
}
|
||||
@@ -93,13 +93,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
|
||||
if permission.Role != "" && permission.Route != "" && permission.Method != "" {
|
||||
fmt.Println(1)
|
||||
role := models.RoleAndPermission{
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Route,
|
||||
V2: permission.Method,
|
||||
Ptype: "p",
|
||||
V0: permission.Role,
|
||||
V1: permission.Route,
|
||||
V2: permission.Method,
|
||||
Category: "route",
|
||||
}
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
|
||||
if err != nil {
|
||||
app.Http.Database.Create(&role)
|
||||
}
|
||||
|
||||
@@ -349,9 +349,9 @@ func SaasWebhookInHandler(c *fiber.Ctx) error {
|
||||
cfg.Nombre, cfg.ID, c.IP(), c.Method())
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"integracion": cfg.Nombre,
|
||||
"saas_api_id": cfg.ID,
|
||||
"message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
|
||||
"ok": true,
|
||||
"integracion": cfg.Nombre,
|
||||
"saas_api_id": cfg.ID,
|
||||
"message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,17 +167,17 @@ func UpdateServidor(c *fiber.Ctx) error {
|
||||
|
||||
// Usar map para Updates y así no perder campos con valor cero (incluido hostinger_vps_id=nil)
|
||||
updateMap := map[string]interface{}{
|
||||
"nombre": m.Nombre,
|
||||
"ip_servidor": m.IpServidor,
|
||||
"so": m.So,
|
||||
"vencimiento": m.Vencimiento,
|
||||
"ram": m.Ram,
|
||||
"nucleos": m.Nucleos,
|
||||
"disco": m.Disco,
|
||||
"ultimo_ping": m.UltimoPing,
|
||||
"prov_servidor_id": m.ProvServidorID,
|
||||
"tipo_servidor_id": m.TipoServidorID,
|
||||
"hostinger_vps_id": m.HostingerVpsID,
|
||||
"nombre": m.Nombre,
|
||||
"ip_servidor": m.IpServidor,
|
||||
"so": m.So,
|
||||
"vencimiento": m.Vencimiento,
|
||||
"ram": m.Ram,
|
||||
"nucleos": m.Nucleos,
|
||||
"disco": m.Disco,
|
||||
"ultimo_ping": m.UltimoPing,
|
||||
"prov_servidor_id": m.ProvServidorID,
|
||||
"tipo_servidor_id": m.TipoServidorID,
|
||||
"hostinger_vps_id": m.HostingerVpsID,
|
||||
"hostinger_subscription_id": m.HostingerSubscriptionID,
|
||||
}
|
||||
if err := app.Http.Database.DB.Model(&models.Servidor{}).Where("id = ?", uid).Updates(updateMap).Error; err != nil {
|
||||
|
||||
@@ -36,13 +36,12 @@ func GetTipoServidor(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"registros": records,
|
||||
"total": total,
|
||||
"registros": records,
|
||||
"total": total,
|
||||
"totalPages": 1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func CreateTipoServidor(c *fiber.Ctx) error {
|
||||
var m models.TipoServidor
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -611,3 +612,65 @@ func GetUmindEventosHandler(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": items})
|
||||
}
|
||||
|
||||
// ─── Consumo ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
|
||||
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
|
||||
// Sin fechas, el rango es el mes en curso.
|
||||
func GetUmindUsoHandler(c *fiber.Ctx) error {
|
||||
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
||||
|
||||
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
|
||||
|
||||
total := 0.0
|
||||
for _, r := range resumen {
|
||||
total += r.Costo
|
||||
}
|
||||
|
||||
out := fiber.Map{
|
||||
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
|
||||
"resumen": resumen, "detalle": detalle,
|
||||
"total_periodo": total, "pendiente_facturar": pendiente,
|
||||
}
|
||||
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
||||
out["plan"] = fiber.Map{
|
||||
"nombre": plan.Nombre, "moneda": plan.Moneda,
|
||||
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
|
||||
"max_agentes": plan.MaxAgentes,
|
||||
}
|
||||
}
|
||||
return c.JSON(out)
|
||||
}
|
||||
|
||||
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
|
||||
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
|
||||
// romperle la pantalla al usuario por un query param mal escrito.
|
||||
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
|
||||
ahora := time.Now()
|
||||
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
|
||||
hasta := desde.AddDate(0, 1, 0)
|
||||
|
||||
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
|
||||
desde = d
|
||||
}
|
||||
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
|
||||
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
|
||||
}
|
||||
if !hasta.After(desde) {
|
||||
hasta = desde.AddDate(0, 1, 0)
|
||||
}
|
||||
return desde, hasta
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
|
||||
func UserInfo(c *fiber.Ctx) error {
|
||||
users, err := models.GetUserById(c.Params("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -189,8 +189,13 @@ func ApiSendSms(c *fiber.Ctx) error {
|
||||
Para: b.Numero,
|
||||
Mensaje: b.Mensaje,
|
||||
Status: status,
|
||||
MsgID: func() string { if resp != nil { return resp.MsgID() }; return "" }(),
|
||||
Error: errStr,
|
||||
MsgID: func() string {
|
||||
if resp != nil {
|
||||
return resp.MsgID()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Error: errStr,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestWhisperAsrConfigHandler(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
texto, err := services.TranscribirAudioSelfHosted(data, fh.Filename)
|
||||
texto, err := services.TranscribirAudioSelfHosted(0, data, fh.Filename)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user