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
@@ -17,6 +17,10 @@ type PlantillaDocumento struct {
|
|||||||
Version int `json:"version" gorm:"column:version;default:1"`
|
Version int `json:"version" gorm:"column:version;default:1"`
|
||||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
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" }
|
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.
|
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
|
||||||
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
|
// 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) {
|
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
||||||
var item PlantillaDocumento
|
var item PlantillaDocumento
|
||||||
if err := app.Http.Database.DB.
|
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").
|
Order("version DESC").
|
||||||
First(&item).Error; err != nil {
|
First(&item).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,6 +68,28 @@ func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
|||||||
return &item, nil
|
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 {
|
func CreatePlantillaDocumento(p PlantillaDocumento) error {
|
||||||
return app.Http.Database.DB.Create(&p).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 {
|
func DeletePlantillaDocumento(id uint) error {
|
||||||
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).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
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type UmindPlan struct {
|
|||||||
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
|
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
|
||||||
// servicio. 0 = sin tope.
|
// servicio. 0 = sin tope.
|
||||||
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
|
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.
|
// MaxAlmacenamientoMB acota el repositorio de archivos del espacio.
|
||||||
// 0 = sin límite.
|
// 0 = sin límite.
|
||||||
MaxAlmacenamientoMB int `json:"max_almacenamiento_mb" gorm:"column:max_almacenamiento_mb;default:200"`
|
MaxAlmacenamientoMB int `json:"max_almacenamiento_mb" gorm:"column:max_almacenamiento_mb;default:200"`
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import (
|
|||||||
|
|
||||||
// Tipos de consumo medible.
|
// Tipos de consumo medible.
|
||||||
const (
|
const (
|
||||||
UsoTipoIA = "ia"
|
UsoTipoIA = "ia"
|
||||||
UsoTipoOCR = "ocr"
|
UsoTipoOCR = "ocr"
|
||||||
UsoTipoWhisper = "whisper"
|
UsoTipoWhisper = "whisper"
|
||||||
|
UsoTipoDocumento = "documento"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UmindUso es una línea de consumo facturable. El Costo se congela con el
|
// 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
|
return cantidad * plan.PrecioPorOCR
|
||||||
case UsoTipoWhisper:
|
case UsoTipoWhisper:
|
||||||
return cantidad * plan.PrecioPorTranscripcion
|
return cantidad * plan.PrecioPorTranscripcion
|
||||||
|
case UsoTipoDocumento:
|
||||||
|
return cantidad * plan.PrecioPorDocumento
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Los avisos solo existen para el dueño. En el widget público escribe
|
||||||
// cualquiera, y programar recordatorios gasta el plan de otro.
|
// cualquiera, y programar recordatorios gasta el plan de otro.
|
||||||
if sesionInterna {
|
if sesionInterna {
|
||||||
@@ -179,6 +186,10 @@ func ejecutarHerramienta(agenteID uint, sessionID string, sesionInterna bool, na
|
|||||||
return executeUmindEmailTool(agenteID, name, args)
|
return executeUmindEmailTool(agenteID, name, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if name == "generar_documento" {
|
||||||
|
return executeUmindDocumentoTool(agenteID, args)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(name, "_aviso") || name == "listar_avisos" {
|
if strings.HasSuffix(name, "_aviso") || name == "listar_avisos" {
|
||||||
if !sesionInterna {
|
if !sesionInterna {
|
||||||
return `{"error": "los recordatorios solo puede programarlos el dueño desde su canal privado"}`
|
return `{"error": "los recordatorios solo puede programarlos el dueño desde su canal privado"}`
|
||||||
|
|||||||
@@ -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(`<h1>{{.Cliente}}</h1>{{range .Items}}<p>{{.Descripcion}}</p>{{end}}`); err != nil {
|
||||||
|
t.Errorf("una plantilla válida fue rechazada: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidarPlantillaHTML(`<h1>{{.Cliente}</h1>`); 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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", r(controllers.GetUmindConexionesHandler)...)
|
||||||
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
|
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.Get("/umind/acciones", w(controllers.GetUmindAccionesHandler)...)
|
||||||
g.Post("/umind/acciones/:id/aprobar", w(controllers.AprobarUmindAccionHandler)...)
|
g.Post("/umind/acciones/:id/aprobar", w(controllers.AprobarUmindAccionHandler)...)
|
||||||
g.Post("/umind/acciones/:id/rechazar", w(controllers.RechazarUmindAccionHandler)...)
|
g.Post("/umind/acciones/:id/rechazar", w(controllers.RechazarUmindAccionHandler)...)
|
||||||
|
|||||||
Reference in New Issue
Block a user