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
@@ -0,0 +1,140 @@
package controllers
import (
"io"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// Las plantillas del cliente: sus cotizaciones y contratos con su membrete.
// Reusan el mismo motor e importador con IA que las del staff — lo único que
// cambia es que llevan tenant_id y no se ven entre clientes.
// GetUmindPlantillasHandler — GET /umind/plantillas?tenant_id=N
func GetUmindPlantillasHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetPlantillasDeTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// ImportarUmindPlantillaHandler convierte un Word/PDF del cliente en plantilla
// con IA. No guarda: devuelve el HTML para que lo revise antes.
// POST /umind/plantillas/importar (multipart: tenant_id, tipo, archivo)
func ImportarUmindPlantillaHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.FormValue("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
tipo := strings.TrimSpace(c.FormValue("tipo"))
if !tipoPlantillaValido(tipo) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo inválido"})
}
fh, err := c.FormFile("archivo")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí el documento a convertir"})
}
if fh.Size > 10<<20 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 10 MB"})
}
f, err := fh.Open()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
defer f.Close()
datos, err := io.ReadAll(io.LimitReader(f, 10<<20))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
texto, err := services.ExtraerTextoDePlantilla(fh.Filename, datos)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
html, err := services.ConvertirEnPlantilla(tipo, texto)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"contenido_html": html})
}
// CreateUmindPlantillaHandler guarda la plantilla del espacio.
// POST /umind/plantillas
func CreateUmindPlantillaHandler(c *fiber.Ctx) error {
var req struct {
TenantID uint `json:"tenant_id"`
Tipo string `json:"tipo"`
Nombre string `json:"nombre"`
ContenidoHTML string `json:"contenido_html"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if !tipoPlantillaValido(req.Tipo) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo inválido"})
}
if strings.TrimSpace(req.ContenidoHTML) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la plantilla está vacía"})
}
// Se valida que compile antes de guardar: una plantilla rota descubierta
// al generar deja al cliente esperando un PDF que nunca llega.
if err := services.ValidarPlantillaHTML(req.ContenidoHTML); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
tid := req.TenantID
nombre := strings.TrimSpace(req.Nombre)
if nombre == "" {
nombre = req.Tipo
}
p := models.PlantillaDocumento{
Tipo: req.Tipo, Nombre: nombre, ContenidoHTML: req.ContenidoHTML,
Version: models.SiguienteVersionPlantilla(tid, req.Tipo), Activa: true, TenantID: &tid,
}
if err := models.CreatePlantillaDocumento(p); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true})
}
// DeleteUmindPlantillaHandler — DELETE /umind/plantillas/:id
func DeleteUmindPlantillaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
p, err := models.GetPlantillaDocumentoByID(uint(id))
if err != nil {
return errSinAcceso(c)
}
// Una plantilla global del staff no se borra desde acá ni por error.
if p.TenantID == nil {
return errSinAcceso(c)
}
if err := accesoTenant(c, *p.TenantID); err != nil {
return err
}
if err := models.DeletePlantillaDocumento(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func tipoPlantillaValido(t string) bool {
switch t {
case "cotizacion", "contrato", "acta", "cuenta_cobro":
return true
}
return false
}