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