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>
141 lines
4.9 KiB
Go
141 lines
4.9 KiB
Go
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
|
|
}
|