El motor de PDF ya existía entero para el staff — Chrome headless, text/template
con {{range .Items}}, y hasta el importador que convierte un Word en plantilla
con IA. Lo único que faltaba era que fueran de cada cliente.
PlantillaDocumento gana TenantID *uint: nulo = global del staff (lo de
siempre), con valor = del espacio. Mismo patrón exacto que AiConfig, el que ya
tiene su lección aprendida. Y GetPlantillaDocumentoActiva ahora filtra
tenant_id IS NULL explícitamente: sin eso, la plantilla que un cliente escribe
para su propio contrato podía salir en un documento de la empresa. Hay test.
Un cliente sin plantilla propia cae a la global, así puede emitir una
cotización desde el primer día y personalizarla cuando quiera. Guardar crea
versión nueva en vez de pisar la vieja: si la nueva sale mal, la anterior sigue
ahí. Y se valida que compile ANTES de guardar — una plantilla rota descubierta
al generar deja al cliente esperando un PDF que nunca llega.
La tool solo se le ofrece al modelo si hay alguna plantilla disponible:
prometerle una capacidad que después falla es peor que no tenerla.
El semáforo de tres: cada PDF levanta un Chrome entero, y cien clientes
generando a la vez son cien navegadores. Eso tira el servidor mucho antes que
cualquier consulta a la IA, así que va desde el día uno y no cuando se caiga.
El JSON de ítems mal formado no tumba la generación — sale el documento sin la
tabla, que todavía se puede corregir a mano. Y sin cantidad se asume 1: el
modelo la omite seguido, y un total en cero es peor que uno aproximado.
Cada documento se cobra (levanta un Chrome) y queda en los archivos del
espacio, descargable como cualquier otro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
8.5 KiB
Go
238 lines
8.5 KiB
Go
package models
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Tipos de consumo medible.
|
|
const (
|
|
UsoTipoIA = "ia"
|
|
UsoTipoOCR = "ocr"
|
|
UsoTipoWhisper = "whisper"
|
|
UsoTipoDocumento = "documento"
|
|
)
|
|
|
|
// UmindUso es una línea de consumo facturable. El Costo se congela con el
|
|
// precio vigente del plan al momento de registrarlo: subir un precio nunca
|
|
// revalúa consumo ya ocurrido, que es lo que haría imposible defender una
|
|
// factura ante un reclamo.
|
|
//
|
|
// TenantID está desnormalizado a propósito (se puede derivar del agente) para
|
|
// poder sumar el consumo de un ciclo sin joins.
|
|
type UmindUso struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
|
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
|
Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"`
|
|
Cantidad float64 `json:"cantidad" gorm:"column:cantidad"`
|
|
Unidad string `json:"unidad" gorm:"column:unidad;size:20"`
|
|
Costo float64 `json:"costo" gorm:"column:costo"`
|
|
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
|
|
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
|
|
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
|
|
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
|
|
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
|
|
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
|
|
// cobrar dos veces por lo mismo.
|
|
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
|
|
}
|
|
|
|
func (UmindUso) TableName() string { return "umind_uso" }
|
|
|
|
// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es
|
|
// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si
|
|
// falla, queda en el log para reconciliar a mano.
|
|
func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) {
|
|
if agenteID == 0 || cantidad <= 0 {
|
|
return
|
|
}
|
|
agente, err := GetUmindAgenteByID(agenteID)
|
|
if err != nil {
|
|
log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err)
|
|
return
|
|
}
|
|
|
|
plan := GetPlanDeTenant(agente.TenantID)
|
|
moneda := "COP"
|
|
if plan != nil {
|
|
moneda = plan.Moneda
|
|
}
|
|
|
|
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
|
|
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
|
|
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
|
|
// servicios, los use quien los use.
|
|
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
|
|
|
|
costo := 0.0
|
|
if !propia {
|
|
costo = costoDeUso(plan, tipo, cantidad)
|
|
}
|
|
|
|
uso := &UmindUso{
|
|
TenantID: agente.TenantID, AgenteID: agenteID,
|
|
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
|
|
Costo: costo, Moneda: moneda, CuentaPropia: propia,
|
|
}
|
|
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
|
|
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
|
|
}
|
|
}
|
|
|
|
// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no
|
|
// tarifado, el consumo se registra pero no cuesta.
|
|
func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
|
|
if plan == nil {
|
|
return 0
|
|
}
|
|
switch tipo {
|
|
case UsoTipoIA:
|
|
return cantidad / 1000 * plan.PrecioPor1kTokens
|
|
case UsoTipoOCR:
|
|
return cantidad * plan.PrecioPorOCR
|
|
case UsoTipoWhisper:
|
|
return cantidad * plan.PrecioPorTranscripcion
|
|
case UsoTipoDocumento:
|
|
return cantidad * plan.PrecioPorDocumento
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos.
|
|
func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) {
|
|
var items []UmindUso
|
|
db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta)
|
|
if tipo != "" {
|
|
db = db.Where("tipo = ?", tipo)
|
|
}
|
|
err := db.Order("created_at DESC").Limit(1000).Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta
|
|
// al correo de cobro.
|
|
type ResumenUso struct {
|
|
Tipo string `json:"tipo"`
|
|
Unidad string `json:"unidad"`
|
|
Cantidad float64 `json:"cantidad"`
|
|
Costo float64 `json:"costo"`
|
|
Eventos int64 `json:"eventos"`
|
|
}
|
|
|
|
func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) {
|
|
var out []ResumenUso
|
|
err := app.Http.Database.DB.Model(&UmindUso{}).
|
|
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
|
|
Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta).
|
|
Group("tipo").Scan(&out).Error
|
|
return out, err
|
|
}
|
|
|
|
// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant —
|
|
// es lo que se le suma a la mensualidad al generar el link de cobro.
|
|
func SumarUsoPendiente(tenantID uint) (float64, error) {
|
|
var total float64
|
|
err := app.Http.Database.DB.Model(&UmindUso{}).
|
|
Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID).
|
|
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error
|
|
return total, err
|
|
}
|
|
|
|
// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente
|
|
// por construcción: el filtro facturado_at IS NULL hace que una segunda
|
|
// llamada (webhook de pago duplicado) no encuentre nada que marcar.
|
|
func MarcarUsoFacturado(tenantID uint) error {
|
|
ahora := time.Now()
|
|
return app.Http.Database.DB.Model(&UmindUso{}).
|
|
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
|
|
Update("facturado_at", ahora).Error
|
|
}
|
|
|
|
// ─── Puente con la facturación por contrato ─────────────────────────────────
|
|
// El cobro recurrente ya existente vive en Contrato (ver renovacion_service).
|
|
// Un contrato es de un Cliente, y un Cliente puede tener varios tenants de
|
|
// uMind, así que el consumo se agrega por cliente, no por tenant.
|
|
|
|
func tenantIDsDeCliente(clienteID uint) []uint {
|
|
if clienteID == 0 {
|
|
return nil
|
|
}
|
|
tenants, err := GetUmindTenantsByClientes([]uint{clienteID})
|
|
if err != nil || len(tenants) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]uint, 0, len(tenants))
|
|
for _, t := range tenants {
|
|
ids = append(ids, t.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// ConsumoPendientePorCliente suma lo consumido y todavía no facturado de
|
|
// todos los tenants de un cliente. Es lo que se le agrega a la mensualidad
|
|
// al generar el link de cobro del ciclo.
|
|
func ConsumoPendientePorCliente(clienteID uint) float64 {
|
|
ids := tenantIDsDeCliente(clienteID)
|
|
if len(ids) == 0 {
|
|
return 0
|
|
}
|
|
var total float64
|
|
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
|
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
|
|
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error; err != nil {
|
|
log.Printf("[UMIND_USO] no se pudo sumar el consumo pendiente del cliente %d: %v", clienteID, err)
|
|
return 0
|
|
}
|
|
return total
|
|
}
|
|
|
|
// ResumenPendientePorCliente es el desglose que se adjunta al correo de cobro.
|
|
// Un cobro variable sin detalle es una disputa asegurada.
|
|
func ResumenPendientePorCliente(clienteID uint) []ResumenUso {
|
|
ids := tenantIDsDeCliente(clienteID)
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
var out []ResumenUso
|
|
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
|
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
|
|
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
|
|
Group("tipo").Scan(&out).Error; err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MarcarUsoFacturadoPorCliente cierra el consumo pendiente tras confirmarse
|
|
// el pago. Idempotente por el filtro facturado_at IS NULL: un webhook de pago
|
|
// repetido no encuentra nada que marcar y no vuelve a cobrar.
|
|
func MarcarUsoFacturadoPorCliente(clienteID uint) {
|
|
ids := tenantIDsDeCliente(clienteID)
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
ahora := time.Now()
|
|
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
|
Where("tenant_id IN ? AND facturado_at IS NULL", ids).
|
|
Update("facturado_at", ahora).Error; err != nil {
|
|
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
|
|
}
|
|
}
|
|
|
|
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
|
|
// el cliente (con tenant), en vez de a una nuestra.
|
|
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
|
|
if agente == nil || agente.AiConfigID == nil {
|
|
return false
|
|
}
|
|
var cfg AiConfig
|
|
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
|
|
return false
|
|
}
|
|
return cfg.TenantID != nil
|
|
}
|