feat(umind): el agente emite cotizaciones y contratos con la plantilla del cliente

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>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:40:51 -05:00
co-authored by Claude Opus 5
parent 544cba6d34
commit 047837dd23
8 changed files with 480 additions and 4 deletions
+56 -1
View File
@@ -17,6 +17,10 @@ type PlantillaDocumento struct {
Version int `json:"version" gorm:"column:version;default:1"`
Activa bool `json:"activa" gorm:"column:activa;default:true"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
// TenantID separa las plantillas de cada cliente de las globales del
// staff. Nulo = global (el comportamiento de siempre). Mismo patrón que
// AiConfig — el que ya tiene su lección aprendida y su test que la cuida.
TenantID *uint `json:"tenant_id" gorm:"column:tenant_id;index"`
}
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
@@ -50,10 +54,13 @@ func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
// GetPlantillaDocumentoActiva devuelve la plantilla GLOBAL del staff. Filtra
// tenant_id IS NULL a propósito: sin eso, la plantilla que un cliente escribe
// para su propio contrato podría salir en un documento de la empresa.
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.
Where("tipo = ? AND activa = ?", tipo, true).
Where("tipo = ? AND activa = ? AND tenant_id IS NULL", tipo, true).
Order("version DESC").
First(&item).Error; err != nil {
return nil, err
@@ -61,6 +68,28 @@ func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
return &item, nil
}
// GetPlantillaDocumentoDeTenant busca la plantilla propia del espacio y, si no
// tiene una para ese tipo, cae a la global. Así un cliente nuevo puede emitir
// una cotización desde el primer día y personalizarla cuando quiera.
func GetPlantillaDocumentoDeTenant(tenantID uint, tipo string) (*PlantillaDocumento, error) {
var item PlantillaDocumento
err := app.Http.Database.DB.
Where("tipo = ? AND activa = ? AND tenant_id = ?", tipo, true, tenantID).
Order("version DESC").First(&item).Error
if err == nil {
return &item, nil
}
return GetPlantillaDocumentoActiva(tipo)
}
// GetPlantillasDeTenant lista las propias del espacio, sin las globales.
func GetPlantillasDeTenant(tenantID uint) ([]PlantillaDocumento, error) {
var items []PlantillaDocumento
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).
Order("tipo ASC, version DESC").Limit(100).Find(&items).Error
return items, err
}
func CreatePlantillaDocumento(p PlantillaDocumento) error {
return app.Http.Database.DB.Create(&p).Error
}
@@ -72,3 +101,29 @@ func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
func DeletePlantillaDocumento(id uint) error {
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
}
// TienePlantillasDocumento dice si el agente puede emitir documentos. Sin
// plantilla la tool no se le ofrece al modelo: prometerle una capacidad que
// después falla es peor que no tenerla.
func TienePlantillasDocumento(agenteID uint) bool {
agente, err := GetUmindAgenteByID(agenteID)
if err != nil {
return false
}
var n int64
app.Http.Database.DB.Model(&PlantillaDocumento{}).
Where("activa = ? AND deleted_at IS NULL AND (tenant_id = ? OR tenant_id IS NULL)", true, agente.TenantID).
Count(&n)
return n > 0
}
// SiguienteVersionPlantilla numera la nueva versión del cliente. Se guarda
// versión nueva en vez de pisar la vieja: si la nueva sale mal, la anterior
// sigue ahí.
func SiguienteVersionPlantilla(tenantID uint, tipo string) int {
var max int
app.Http.Database.DB.Model(&PlantillaDocumento{}).
Where("tenant_id = ? AND tipo = ?", tenantID, tipo).
Select("COALESCE(MAX(version), 0)").Scan(&max)
return max + 1
}
+3
View File
@@ -20,6 +20,9 @@ type UmindPlan struct {
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
// servicio. 0 = sin tope.
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
// PrecioPorDocumento cobra cada PDF generado: cada uno levanta un Chrome
// headless, que es el recurso más caro del servidor.
PrecioPorDocumento float64 `json:"precio_por_documento" gorm:"column:precio_por_documento;default:0"`
// MaxAlmacenamientoMB acota el repositorio de archivos del espacio.
// 0 = sin límite.
MaxAlmacenamientoMB int `json:"max_almacenamiento_mb" gorm:"column:max_almacenamiento_mb;default:200"`
+6 -3
View File
@@ -10,9 +10,10 @@ import (
// Tipos de consumo medible.
const (
UsoTipoIA = "ia"
UsoTipoOCR = "ocr"
UsoTipoWhisper = "whisper"
UsoTipoIA = "ia"
UsoTipoOCR = "ocr"
UsoTipoWhisper = "whisper"
UsoTipoDocumento = "documento"
)
// UmindUso es una línea de consumo facturable. El Costo se congela con el
@@ -95,6 +96,8 @@ func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
return cantidad * plan.PrecioPorOCR
case UsoTipoWhisper:
return cantidad * plan.PrecioPorTranscripcion
case UsoTipoDocumento:
return cantidad * plan.PrecioPorDocumento
}
return 0
}