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,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
|
||||
}
|
||||
Reference in New Issue
Block a user