From 047837dd23abea633eb4f618859f77ac2f365e8c Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:40:51 -0500 Subject: [PATCH] feat(umind): el agente emite cotizaciones y contratos con la plantilla del cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/models/plantilla_documento.go | 57 +++++- pkg/models/umind_plan.go | 3 + pkg/models/umind_uso.go | 9 +- pkg/services/umind_agent_service.go | 11 + pkg/services/umind_documento_test.go | 71 +++++++ pkg/services/umind_documento_tools.go | 189 ++++++++++++++++++ .../controllers/umind_plantilla_controller.go | 140 +++++++++++++ rest/routes/umind.go | 4 + 8 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 pkg/services/umind_documento_test.go create mode 100644 pkg/services/umind_documento_tools.go create mode 100644 rest/controllers/umind_plantilla_controller.go diff --git a/pkg/models/plantilla_documento.go b/pkg/models/plantilla_documento.go index af857d1..72a6cfd 100644 --- a/pkg/models/plantilla_documento.go +++ b/pkg/models/plantilla_documento.go @@ -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 +} diff --git a/pkg/models/umind_plan.go b/pkg/models/umind_plan.go index d737fe5..fd24789 100644 --- a/pkg/models/umind_plan.go +++ b/pkg/models/umind_plan.go @@ -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"` diff --git a/pkg/models/umind_uso.go b/pkg/models/umind_uso.go index 9b9ddf0..2223665 100644 --- a/pkg/models/umind_uso.go +++ b/pkg/models/umind_uso.go @@ -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 } diff --git a/pkg/services/umind_agent_service.go b/pkg/services/umind_agent_service.go index be41db1..d6ed03d 100644 --- a/pkg/services/umind_agent_service.go +++ b/pkg/services/umind_agent_service.go @@ -57,6 +57,13 @@ func umindTools(agenteID uint, sesionInterna bool) []agentTool { }, }} + // Generar documentos está disponible siempre: preparar un borrador es + // barato y reversible. Lo que espera aprobación es entregarlo, y de eso + // se encarga la puerta de executeUmindTool. + if models.TienePlantillasDocumento(agenteID) { + tools = append(tools, umindDocumentoTools()...) + } + // Los avisos solo existen para el dueño. En el widget público escribe // cualquiera, y programar recordatorios gasta el plan de otro. if sesionInterna { @@ -179,6 +186,10 @@ func ejecutarHerramienta(agenteID uint, sessionID string, sesionInterna bool, na return executeUmindEmailTool(agenteID, name, args) } + if name == "generar_documento" { + return executeUmindDocumentoTool(agenteID, args) + } + if strings.HasSuffix(name, "_aviso") || name == "listar_avisos" { if !sesionInterna { return `{"error": "los recordatorios solo puede programarlos el dueño desde su canal privado"}` diff --git a/pkg/services/umind_documento_test.go b/pkg/services/umind_documento_test.go new file mode 100644 index 0000000..5d9ef9a --- /dev/null +++ b/pkg/services/umind_documento_test.go @@ -0,0 +1,71 @@ +package services + +import ( + "os" + "strings" + "testing" +) + +func TestParsearItems(t *testing.T) { + items, total := parsearItems(`[{"descripcion":"Licencia","cantidad":3,"precio":200000}]`) + if len(items) != 1 || total != 600000 { + t.Fatalf("items=%d total=%v, esperaba 1 ítem y 600000", len(items), total) + } + // Sin cantidad se asume 1: el modelo la omite seguido y un total en cero + // en una cotización es peor que uno aproximado. + items, total = parsearItems(`[{"descripcion":"Servicio","precio":50000}]`) + if len(items) != 1 || items[0].Cantidad != 1 || total != 50000 { + t.Errorf("cantidad omitida: items=%+v total=%v", items, total) + } + // JSON roto no puede tumbar la generación entera. + if items, total := parsearItems(`{no es json`); items != nil || total != 0 { + t.Errorf("JSON inválido debería dar vacío, dio %+v / %v", items, total) + } + if items, _ := parsearItems(""); items != nil { + t.Error("sin ítems debería dar vacío") + } +} + +func TestValidarPlantillaHTML(t *testing.T) { + if err := ValidarPlantillaHTML(`

{{.Cliente}}

{{range .Items}}

{{.Descripcion}}

{{end}}`); err != nil { + t.Errorf("una plantilla válida fue rechazada: %v", err) + } + if err := ValidarPlantillaHTML(`

{{.Cliente}

`); err == nil { + t.Error("una plantilla rota tiene que rechazarse antes de guardarse") + } +} + +// Cada PDF levanta un Chrome entero. Sin semáforo, cien clientes generando a +// la vez son cien navegadores y el servidor se cae antes que por la IA. +func TestGeneracionDePDFTieneSemaforo(t *testing.T) { + b, err := os.ReadFile("umind_documento_tools.go") + if err != nil { + t.Fatal(err) + } + s := string(b) + if cap(pdfEnCurso) == 0 || cap(pdfEnCurso) > 5 { + t.Errorf("el semáforo de PDF tiene capacidad %d: debería ser chica y mayor que cero", cap(pdfEnCurso)) + } + i := strings.Index(s, "func renderPDFDesdePlantilla") + cuerpo := s[i:] + cuerpo = cuerpo[:strings.Index(cuerpo, "\n// guardarPDF")] + if !strings.Contains(cuerpo, "pdfEnCurso <- struct{}{}") || !strings.Contains(cuerpo, "<-pdfEnCurso") { + t.Error("RenderHTMLToPDF tiene que llamarse con el semáforo tomado y liberarlo después") + } +} + +// Las plantillas globales del staff no pueden salir en un documento de un +// cliente, ni al revés. Es el mismo patrón que ya se corrigió en AiConfig. +func TestPlantillaGlobalFiltraPorTenantNulo(t *testing.T) { + b, err := os.ReadFile("../models/plantilla_documento.go") + if err != nil { + t.Fatal(err) + } + s := string(b) + i := strings.Index(s, "func GetPlantillaDocumentoActiva") + cuerpo := s[i:] + cuerpo = cuerpo[:strings.Index(cuerpo, "\n// GetPlantillaDocumentoDeTenant")] + if !strings.Contains(cuerpo, "tenant_id IS NULL") { + t.Error("la plantilla global tiene que filtrar tenant_id IS NULL, si no puede devolver la de un cliente") + } +} diff --git a/pkg/services/umind_documento_tools.go b/pkg/services/umind_documento_tools.go new file mode 100644 index 0000000..4d621ce --- /dev/null +++ b/pkg/services/umind_documento_tools.go @@ -0,0 +1,189 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// Generación de documentos para los agentes: cotizaciones y contratos armados +// desde la conversación, con la plantilla del propio cliente. + +// pdfEnCurso acota cuántos Chrome headless corren a la vez. Cada PDF levanta +// un navegador entero: cien clientes generando documentos al mismo tiempo son +// cien Chromes, y eso tira el servidor antes que cualquier consulta a la IA. +var pdfEnCurso = make(chan struct{}, 3) + +func umindDocumentoTools() []agentTool { + return []agentTool{{ + Type: "function", + Function: agentToolFunc{ + Name: "generar_documento", + Description: "Genera un documento en PDF (cotización, contrato, acta o cuenta de cobro) con la plantilla del negocio. " + + "Usalo cuando el cliente pide una cotización o un presupuesto formal.", + Parameters: agentToolParam{ + Type: "object", + Properties: map[string]agentToolParam{ + "tipo": {Type: "string", Description: "cotizacion | contrato | acta | cuenta_cobro"}, + "cliente": {Type: "string", Description: "Nombre de la persona o empresa a la que va dirigido"}, + "items": {Type: "string", Description: `Los ítems en JSON: [{"descripcion":"...","cantidad":1,"precio":100000}]`}, + "notas": {Type: "string", Description: "Condiciones o aclaraciones, opcional"}, + }, + Required: []string{"tipo", "cliente"}, + }, + }, + }} +} + +func executeUmindDocumentoTool(agenteID uint, args map[string]interface{}) string { + tipo, _ := args["tipo"].(string) + tipo = strings.ToLower(strings.TrimSpace(tipo)) + switch tipo { + case "cotizacion", "contrato", "acta", "cuenta_cobro": + default: + return `{"error": "tipo inválido: usá cotizacion, contrato, acta o cuenta_cobro"}` + } + cliente, _ := args["cliente"].(string) + if strings.TrimSpace(cliente) == "" { + return `{"error": "falta a nombre de quién va el documento"}` + } + + agente, err := models.GetUmindAgenteByID(agenteID) + if err != nil { + return `{"error": "no se pudo generar el documento"}` + } + tenant, err := models.GetUmindTenantByID(agente.TenantID) + if err != nil { + return `{"error": "no se pudo generar el documento"}` + } + plantilla, err := models.GetPlantillaDocumentoDeTenant(agente.TenantID, tipo) + if err != nil { + return fmt.Sprintf(`{"error": "el negocio todavía no tiene una plantilla de %s cargada"}`, tipo) + } + + items, total := parsearItems(args["items"]) + notas, _ := args["notas"].(string) + datos := map[string]interface{}{ + "Fecha": time.Now().Format("02/01/2006"), "Cliente": cliente, + "EmpresaNombre": tenant.Nombre, "Items": items, "Total": total, + "Notas": notas, "Numero": time.Now().Format("20060102-1504"), + } + + pdf, err := renderPDFDesdePlantilla(plantilla.ContenidoHTML, datos) + if err != nil { + models.RegistrarEventoUmind(agenteID, "error", "documento", "No se pudo generar un documento", err.Error()) + return `{"error": "no se pudo generar el documento"}` + } + + archivo, err := guardarPDFEnRepositorio(agente.TenantID, fmt.Sprintf("%s-%s", tipo, cliente), pdf) + if err != nil { + return `{"error": "el documento se generó pero no se pudo guardar"}` + } + models.RegistrarUsoUmind(agenteID, models.UsoTipoDocumento, 1, "documento") + + b, _ := json.Marshal(map[string]interface{}{ + "ok": true, "archivo_id": archivo.ID, "nombre": archivo.Nombre, "total": total, + "nota": "El documento quedó guardado en los archivos del negocio.", + }) + return string(b) +} + +// renderPDFDesdePlantilla ejecuta la plantilla y la manda a Chrome, con el +// semáforo puesto. +func renderPDFDesdePlantilla(contenidoHTML string, datos map[string]interface{}) ([]byte, error) { + tmpl, err := template.New("doc").Parse(contenidoHTML) + if err != nil { + return nil, fmt.Errorf("la plantilla tiene un error de formato: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, datos); err != nil { + return nil, fmt.Errorf("no se pudo completar la plantilla: %w", err) + } + + pdfEnCurso <- struct{}{} + defer func() { <-pdfEnCurso }() + return RenderHTMLToPDF(buf.String()) +} + +// guardarPDFEnRepositorio deja el documento en los archivos del espacio, para +// que quede a la vista y descargable como cualquier otro. +func guardarPDFEnRepositorio(tenantID uint, nombreBase string, pdf []byte) (*models.UmindArchivo, error) { + dir := filepath.Join("uploads", "umind", strconv.FormatUint(uint64(tenantID), 10)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + seguro := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '-' + }, strings.ToLower(nombreBase)) + nombre := fmt.Sprintf("%s-%d.pdf", seguro, time.Now().Unix()) + destino := filepath.Join(dir, nombre) + if err := os.WriteFile(destino, pdf, 0o644); err != nil { + return nil, err + } + archivo := &models.UmindArchivo{ + TenantID: tenantID, Nombre: nombre, Archivo: destino, + TipoMime: "application/pdf", Tamanio: int64(len(pdf)), Origen: "generado", + } + if err := models.CreateUmindArchivo(archivo); err != nil { + _ = os.Remove(destino) + return nil, err + } + return archivo, nil +} + +type itemDocumento struct { + Descripcion string + Cantidad float64 + Precio float64 + Subtotal float64 +} + +// parsearItems acepta el JSON que arma el modelo. Si viene mal formado se +// devuelve vacío en vez de fallar: un documento sin la tabla de ítems todavía +// se puede corregir a mano; uno que no se generó hace perder la conversación. +func parsearItems(raw interface{}) ([]itemDocumento, float64) { + s, _ := raw.(string) + if strings.TrimSpace(s) == "" { + return nil, 0 + } + var crudos []struct { + Descripcion string `json:"descripcion"` + Cantidad float64 `json:"cantidad"` + Precio float64 `json:"precio"` + } + if err := json.Unmarshal([]byte(s), &crudos); err != nil { + return nil, 0 + } + items := make([]itemDocumento, 0, len(crudos)) + total := 0.0 + for _, c := range crudos { + if c.Cantidad == 0 { + c.Cantidad = 1 + } + sub := c.Cantidad * c.Precio + total += sub + items = append(items, itemDocumento{c.Descripcion, c.Cantidad, c.Precio, sub}) + } + return items, total +} + +// ValidarPlantillaHTML confirma que la plantilla compila antes de guardarla: +// descubrir el error al generar deja al cliente esperando un PDF que nunca +// llega, y del lado de adentro no hay a quién preguntarle. +func ValidarPlantillaHTML(contenido string) error { + if _, err := template.New("v").Parse(contenido); err != nil { + return fmt.Errorf("la plantilla tiene un error de formato: %w", err) + } + return nil +} diff --git a/rest/controllers/umind_plantilla_controller.go b/rest/controllers/umind_plantilla_controller.go new file mode 100644 index 0000000..0a8ed7e --- /dev/null +++ b/rest/controllers/umind_plantilla_controller.go @@ -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 +} diff --git a/rest/routes/umind.go b/rest/routes/umind.go index 079730e..944c20c 100644 --- a/rest/routes/umind.go +++ b/rest/routes/umind.go @@ -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)...)