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:
co-authored by
Claude Opus 5
parent
544cba6d34
commit
047837dd23
@@ -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
|
||||
}
|
||||
@@ -65,6 +65,10 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
|
||||
|
||||
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
|
||||
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
|
||||
g.Get("/umind/plantillas", w(controllers.GetUmindPlantillasHandler)...)
|
||||
g.Post("/umind/plantillas", w(controllers.CreateUmindPlantillaHandler)...)
|
||||
g.Post("/umind/plantillas/importar", w(controllers.ImportarUmindPlantillaHandler)...)
|
||||
g.Delete("/umind/plantillas/:id", w(controllers.DeleteUmindPlantillaHandler)...)
|
||||
g.Get("/umind/acciones", w(controllers.GetUmindAccionesHandler)...)
|
||||
g.Post("/umind/acciones/:id/aprobar", w(controllers.AprobarUmindAccionHandler)...)
|
||||
g.Post("/umind/acciones/:id/rechazar", w(controllers.RechazarUmindAccionHandler)...)
|
||||
|
||||
Reference in New Issue
Block a user