feat: automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
Implementa las 4 fases de la especificación de automatización: módulo de plantillas/tarifas editable por el equipo, generación de PDF (HTML+JS vía Chrome headless) para cotizaciones/contratos/arquitecturas/cuentas de cobro, chat propio en el dashboard reutilizando el mismo motor y tools del bot de Telegram, y nuevas tools del agente para crear estos documentos end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
772a4a5656
commit
516220a8a1
@@ -0,0 +1,57 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarArquitectura produce el PDF de una propuesta técnica a partir de un
|
||||
// requerimiento en texto libre. Quien llama (Claude) ya consultó
|
||||
// listar_arquitecturas_referencia para reutilizar patrones ya resueltos en vez de
|
||||
// improvisar, y trae la propuesta ya armada; el backend solo la renderiza a PDF y,
|
||||
// si aplica, la deja guardada como nueva referencia reutilizable.
|
||||
func GenerarArquitectura(requerimiento, propuesta, nombre string, clienteID *uint, guardarComoReferencia bool, generadoPor string) (*models.DocumentoGenerado, error) {
|
||||
if requerimiento == "" {
|
||||
return nil, fmt.Errorf("requerimiento requerido")
|
||||
}
|
||||
if propuesta == "" {
|
||||
return nil, fmt.Errorf("propuesta requerida")
|
||||
}
|
||||
if nombre == "" {
|
||||
nombre = "Propuesta técnica"
|
||||
}
|
||||
|
||||
var cliente *models.Cliente
|
||||
if clienteID != nil {
|
||||
c, err := models.GetClienteByID(*clienteID)
|
||||
if err == nil {
|
||||
cliente = c
|
||||
}
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"Nombre": nombre,
|
||||
"Requerimiento": requerimiento,
|
||||
"Propuesta": propuesta,
|
||||
"Cliente": cliente,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("arquitectura", datos, clienteID, nil, generadoPor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if guardarComoReferencia {
|
||||
ref := &models.Arquitectura{
|
||||
Nombre: nombre,
|
||||
Descripcion: requerimiento,
|
||||
ContenidoHTML: propuesta,
|
||||
EsReferencia: true,
|
||||
ClienteID: clienteID,
|
||||
}
|
||||
_ = models.CreateArquitectura(ref) // no bloquea la generación del documento si falla
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarDocumentoContrato produce el PDF de un contrato ya existente a partir de
|
||||
// la plantilla activa tipo 'contrato' (cláusulas estándar) + los datos del contrato
|
||||
// (cliente, servicios, vigencia, valor). Es la implementación única que comparten
|
||||
// el endpoint POST /api/v2/contratos/:id/generar-documento y la tool crear_contrato.
|
||||
func GenerarDocumentoContrato(contratoID uint, generadoPor string) (*models.DocumentoGenerado, error) {
|
||||
contrato, err := models.GetContratoByID(contratoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contrato no encontrado: %w", err)
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"Contrato": contrato,
|
||||
"Cliente": contrato.Cliente,
|
||||
"Servicios": contrato.Servicios,
|
||||
"FechaInicio": contrato.FechaInicio.Format("02/01/2006"),
|
||||
"FechaVencimiento": contrato.FechaVencimiento.Format("02/01/2006"),
|
||||
"PrecioAcordado": contrato.PrecioAcordado,
|
||||
"Moneda": contrato.Moneda,
|
||||
"Notas": contrato.Notas,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("contrato", datos, &contrato.ClienteID, nil, generadoPor)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
// CrearContratoConDocumento crea el registro de contrato y de una vez genera su PDF
|
||||
// con cláusulas estándar, para el flujo "cliente, tipo de servicio, duración" que
|
||||
// describe la automatización con IA (Telegram / chat propio / Claude directo).
|
||||
func CrearContratoConDocumento(clienteID uint, servicioIDs []uint, duracionMeses int, precioAcordado float64, moneda, notas, generadoPor string) (*models.Contrato, *models.DocumentoGenerado, error) {
|
||||
if clienteID == 0 {
|
||||
return nil, nil, fmt.Errorf("cliente_id requerido")
|
||||
}
|
||||
if len(servicioIDs) == 0 {
|
||||
return nil, nil, fmt.Errorf("servicio_ids requerido (al menos un servicio)")
|
||||
}
|
||||
if duracionMeses <= 0 {
|
||||
duracionMeses = 12
|
||||
}
|
||||
if moneda == "" {
|
||||
moneda = "COP"
|
||||
}
|
||||
|
||||
inicio := time.Now()
|
||||
vencimiento := inicio.AddDate(0, duracionMeses, 0)
|
||||
|
||||
c := models.Contrato{
|
||||
ClienteID: clienteID,
|
||||
FechaInicio: inicio,
|
||||
FechaVencimiento: vencimiento,
|
||||
PrecioAcordado: precioAcordado,
|
||||
Moneda: moneda,
|
||||
Estado: "activo",
|
||||
Notas: notas,
|
||||
}
|
||||
if err := models.CreateContrato(c, servicioIDs, nil, nil, nil); err != nil {
|
||||
return nil, nil, fmt.Errorf("no se pudo crear el contrato: %w", err)
|
||||
}
|
||||
|
||||
// Recargar con Cliente/Servicios precargados (CreateContrato no los devuelve).
|
||||
creado, err := models.GetUltimoContratoByCliente(clienteID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("contrato creado pero no se pudo recargar: %w", err)
|
||||
}
|
||||
|
||||
doc, err := GenerarDocumentoContrato(creado.ID, generadoPor)
|
||||
if err != nil {
|
||||
return creado, nil, err
|
||||
}
|
||||
return creado, doc, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarDocumentoCuentaCobro produce el PDF de solicitud de cuenta de cobro para
|
||||
// una cuenta ya existente, usando la plantilla activa tipo 'cuenta_cobro' + los
|
||||
// datos fiscales de U-SITE (DatosBaseDocumento), para que salga correcta sin
|
||||
// revisión manual.
|
||||
func GenerarDocumentoCuentaCobro(cuentaCobroID uint, generadoPor string) (*models.DocumentoGenerado, error) {
|
||||
cc, err := models.GetCuentaCobroByID(cuentaCobroID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cuenta de cobro no encontrada: %w", err)
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"CuentaCobro": cc,
|
||||
"Cliente": cc.Cliente,
|
||||
"Descripcion": cc.Descripcion,
|
||||
"Valor": cc.Valor,
|
||||
"FechaCuenta": cc.Fecha.Format("02/01/2006"),
|
||||
"Notas": cc.Notas,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("cuenta_cobro", datos, &cc.ClienteID, nil, generadoPor)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
// CrearCuentaCobroConDocumento crea el registro de cuenta de cobro y de una vez
|
||||
// genera su PDF, para el flujo "proyecto, monto, periodo" de la automatización con IA.
|
||||
func CrearCuentaCobroConDocumento(clienteID uint, descripcion string, valor float64, fechaVencimiento *time.Time, notas, generadoPor string) (*models.CuentaCobro, *models.DocumentoGenerado, error) {
|
||||
if clienteID == 0 {
|
||||
return nil, nil, fmt.Errorf("cliente_id requerido")
|
||||
}
|
||||
if valor <= 0 {
|
||||
return nil, nil, fmt.Errorf("valor debe ser mayor a cero")
|
||||
}
|
||||
|
||||
cc := &models.CuentaCobro{
|
||||
ClienteID: clienteID,
|
||||
Fecha: time.Now(),
|
||||
Descripcion: descripcion,
|
||||
Valor: valor,
|
||||
Estado: "pendiente",
|
||||
FechaVencimiento: fechaVencimiento,
|
||||
Notas: notas,
|
||||
}
|
||||
if err := models.CreateCuentaCobro(cc); err != nil {
|
||||
return nil, nil, fmt.Errorf("no se pudo crear la cuenta de cobro: %w", err)
|
||||
}
|
||||
|
||||
doc, err := GenerarDocumentoCuentaCobro(cc.ID, generadoPor)
|
||||
if err != nil {
|
||||
return cc, nil, err
|
||||
}
|
||||
return cc, doc, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarDocumento es el punto único que usan cotizaciones, contratos, actas de
|
||||
// proyecto y cuentas de cobro para producir su PDF: toma la plantilla activa del
|
||||
// tipo pedido (Capa 1, fuente de verdad editable en /plantillas-documento), la
|
||||
// renderiza con los datos que arma cada endpoint, la convierte a PDF y la deja
|
||||
// guardada en el historial (DocumentoGenerado) asociada al cliente/proyecto.
|
||||
func GenerarDocumento(tipo string, datos map[string]interface{}, clienteID, proyectoID *uint, generadoPor string) (*models.DocumentoGenerado, []byte, error) {
|
||||
plantilla, err := models.GetPlantillaDocumentoActiva(tipo)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("no hay una plantilla activa para el tipo '%s': %w", tipo, err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("documento").Parse(plantilla.ContenidoHTML)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("plantilla HTML inválida: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, datos); err != nil {
|
||||
return nil, nil, fmt.Errorf("error al renderizar plantilla: %w", err)
|
||||
}
|
||||
|
||||
pdfBytes, err := RenderHTMLToPDF(buf.String())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s-%s.pdf", tipo, uuid.NewString())
|
||||
subdir := tipo
|
||||
if clienteID != nil {
|
||||
subdir = fmt.Sprintf("%s/%d", tipo, *clienteID)
|
||||
}
|
||||
path, err := SaveGeneratedFile(subdir, filename, pdfBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
doc := &models.DocumentoGenerado{
|
||||
Tipo: tipo,
|
||||
ClienteID: clienteID,
|
||||
ProyectoID: proyectoID,
|
||||
PlantillaID: &plantilla.ID,
|
||||
Nombre: filename,
|
||||
Archivo: path,
|
||||
TipoMime: "application/pdf",
|
||||
Tamanio: int64(len(pdfBytes)),
|
||||
DatosJSON: marshalDatos(datos),
|
||||
GeneradoPor: generadoPor,
|
||||
}
|
||||
if err := models.CreateDocumentoGenerado(doc); err != nil {
|
||||
return nil, nil, fmt.Errorf("documento generado pero no se pudo guardar en el historial: %w", err)
|
||||
}
|
||||
|
||||
return doc, pdfBytes, nil
|
||||
}
|
||||
|
||||
// ItemCotizacion es un renglón de una cotización con el precio ya calculado
|
||||
// por quien llama (Claude, tras consultar /api/v2/tarifas). El backend solo suma.
|
||||
type ItemCotizacion struct {
|
||||
Descripcion string `json:"descripcion"`
|
||||
Cantidad float64 `json:"cantidad"`
|
||||
ValorUnitario float64 `json:"valor_unitario"`
|
||||
Unidad string `json:"unidad"`
|
||||
}
|
||||
|
||||
// CrearCotizacion es la única implementación de "generar cotización": la usan por
|
||||
// igual el endpoint POST /api/v2/cotizaciones y la tool crear_cotizacion del
|
||||
// agente de Telegram/Claude, para que el comportamiento sea idéntico sin importar el canal.
|
||||
func CrearCotizacion(clienteID uint, alcance, tipoProyecto string, items []ItemCotizacion, generadoPor string) (*models.DocumentoGenerado, float64, error) {
|
||||
if clienteID == 0 {
|
||||
return nil, 0, fmt.Errorf("cliente_id requerido")
|
||||
}
|
||||
if alcance == "" {
|
||||
return nil, 0, fmt.Errorf("alcance requerido")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, 0, fmt.Errorf("items requerido (al menos un ítem con descripcion, cantidad y valor_unitario)")
|
||||
}
|
||||
|
||||
cliente, err := models.GetClienteByID(clienteID)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("cliente no encontrado: %w", err)
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, it := range items {
|
||||
total += it.Cantidad * it.ValorUnitario
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"Cliente": cliente,
|
||||
"Alcance": alcance,
|
||||
"TipoProyecto": tipoProyecto,
|
||||
"Items": items,
|
||||
"Total": total,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("cotizacion", datos, &clienteID, nil, generadoPor)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return doc, total, nil
|
||||
}
|
||||
|
||||
// DatosBaseDocumento agrega los campos comunes a cualquier plantilla (fecha,
|
||||
// datos fiscales de U-SITE) al mapa de datos específicos de cada tipo de documento.
|
||||
func DatosBaseDocumento(extra map[string]interface{}) map[string]interface{} {
|
||||
base := map[string]interface{}{
|
||||
"Fecha": time.Now().Format("02/01/2006"),
|
||||
"EmpresaNombre": "U-SITE S.A.S BIC",
|
||||
"EmpresaWeb": "https://u-site.app",
|
||||
}
|
||||
for k, v := range extra {
|
||||
base[k] = v
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func marshalDatos(datos map[string]interface{}) string {
|
||||
b, err := json.Marshal(datos)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// RenderHTMLToPDF convierte un documento HTML (ya renderizado, con JS/CSS inline si aplica)
|
||||
// a PDF usando Chrome headless. Es el motor de "impresión" que comparten cotizaciones,
|
||||
// contratos, actas de proyecto y cuentas de cobro: la plantilla se renderiza a HTML con
|
||||
// text/template y este servicio la convierte al PDF final.
|
||||
//
|
||||
// Requiere Chrome/Chromium instalado en el host (ver Dockerfile). La ruta del binario se
|
||||
// puede forzar con la variable de entorno CHROME_EXEC_PATH; si no está definida, chromedp
|
||||
// intenta ubicar un Chrome instalado en el sistema.
|
||||
func RenderHTMLToPDF(html string) ([]byte, error) {
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
)
|
||||
if execPath := os.Getenv("CHROME_EXEC_PATH"); execPath != "" {
|
||||
opts = append(opts, chromedp.ExecPath(execPath))
|
||||
}
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancelAlloc()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancelTimeout()
|
||||
|
||||
dataURI := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(html))
|
||||
|
||||
var pdfBuf []byte
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(dataURI),
|
||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
buf, _, err := page.PrintToPDF().
|
||||
WithPrintBackground(true).
|
||||
WithPreferCSSPageSize(true).
|
||||
WithMarginTop(0.4).
|
||||
WithMarginBottom(0.4).
|
||||
WithMarginLeft(0.4).
|
||||
WithMarginRight(0.4).
|
||||
Do(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pdfBuf = buf
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generando PDF: %w", err)
|
||||
}
|
||||
return pdfBuf, nil
|
||||
}
|
||||
|
||||
// SaveGeneratedFile persiste bytes generados (ej. un PDF) en disco bajo uploads/documentos/{subdir}
|
||||
// y retorna la ruta relativa guardada en el modelo (DocumentoGenerado.Archivo, ProyectoDocumento.Archivo, etc).
|
||||
func SaveGeneratedFile(subdir, filename string, data []byte) (string, error) {
|
||||
dir := fmt.Sprintf("uploads/documentos/%s", subdir)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("no se pudo crear el directorio %s: %w", dir, err)
|
||||
}
|
||||
path := dir + "/" + filename
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("no se pudo escribir %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
func slugifyProyecto(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
replacer := strings.NewReplacer(
|
||||
" ", "-", "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u",
|
||||
"ñ", "n", "ü", "u",
|
||||
)
|
||||
s = replacer.Replace(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// CrearProyectoSimple crea un proyecto para un cliente a partir del flujo de
|
||||
// automatización con IA ("nombre, stack, cliente asociado"). El stack se antepone
|
||||
// a la descripción; la creación de repo/estructura inicial queda fuera de este
|
||||
// alcance (requiere integración con un proveedor Git, no cubierta aún).
|
||||
func CrearProyectoSimple(clienteID uint, nombre, stack, descripcion string) (*models.Proyecto, error) {
|
||||
if clienteID == 0 {
|
||||
return nil, fmt.Errorf("cliente_id requerido")
|
||||
}
|
||||
if strings.TrimSpace(nombre) == "" {
|
||||
return nil, fmt.Errorf("nombre requerido")
|
||||
}
|
||||
if stack != "" {
|
||||
descripcion = fmt.Sprintf("Stack: %s\n\n%s", stack, descripcion)
|
||||
}
|
||||
p := &models.Proyecto{
|
||||
ClienteID: clienteID,
|
||||
Nombre: nombre,
|
||||
Slug: slugifyProyecto(nombre),
|
||||
Descripcion: descripcion,
|
||||
Color: "#8eb02f",
|
||||
Estado: "activo",
|
||||
}
|
||||
if err := models.CreateProyecto(p); err != nil {
|
||||
return nil, fmt.Errorf("no se pudo crear el proyecto: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -165,6 +165,17 @@ func agentTools() []agentTool {
|
||||
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
|
||||
tool("enviar_correo_contrato", "Envía notificación de renovación por correo al cliente.",
|
||||
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
|
||||
tool("crear_contrato", "Crea un contrato para un cliente con uno o más servicios y genera de una vez su PDF con cláusulas estándar.",
|
||||
obj(map[string]agentToolParam{
|
||||
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
|
||||
"servicio_ids": agentToolParam{Type: "array", Description: "IDs de los servicios contratados", Items: &agentToolParam{Type: "number"}},
|
||||
"duracion_meses": num("Duración del contrato en meses (default 12)"),
|
||||
"precio_acordado": num("Valor total acordado"),
|
||||
"moneda": str("Moneda, ej: COP, USD (default COP)"),
|
||||
"notas": str("Notas adicionales del contrato"),
|
||||
}, []string{"cliente_id", "servicio_ids", "precio_acordado"})),
|
||||
tool("generar_documento_contrato", "Genera (o regenera) el PDF de un contrato ya existente a partir de la plantilla activa.",
|
||||
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
|
||||
|
||||
// ── Contabilidad ─────────────────────────────────────────────────────
|
||||
tool("dashboard_contabilidad", "Resumen del mes: ingresos, egresos, pendientes.",
|
||||
@@ -192,18 +203,72 @@ func agentTools() []agentTool {
|
||||
"page": num("Página"),
|
||||
"estado": str("pendiente | pagada | vencida"),
|
||||
}, nil)),
|
||||
tool("crear_cuenta_cobro", "Crea una cuenta de cobro para un cliente/proyecto y genera de una vez su PDF de solicitud.",
|
||||
obj(map[string]agentToolParam{
|
||||
"cliente_id": num("ID del cliente"),
|
||||
"descripcion": str("Descripción del cobro, ej: proyecto y periodo"),
|
||||
"valor": num("Valor a cobrar"),
|
||||
"notas": str("Notas adicionales"),
|
||||
}, []string{"cliente_id", "descripcion", "valor"})),
|
||||
tool("generar_documento_cuenta_cobro", "Genera (o regenera) el PDF de una cuenta de cobro ya existente.",
|
||||
obj(map[string]agentToolParam{"id": num("ID de la cuenta de cobro")}, []string{"id"})),
|
||||
tool("listar_cuentas_pagar", "Lista cuentas por pagar (lo que debemos).",
|
||||
obj(map[string]agentToolParam{
|
||||
"page": num("Página"),
|
||||
"estado": str("pendiente | pagada | vencida"),
|
||||
}, nil)),
|
||||
|
||||
// ── Automatización IA: cotizaciones y tarifas ───────────────────────────
|
||||
tool("listar_tarifas", "Lista las tarifas activas (valor por hora, licencias, VMs, márgenes) para armar cotizaciones.",
|
||||
obj(map[string]agentToolParam{
|
||||
"categoria": str("Filtrar por categoría: hora_servicio | licencia | vm_azure | margen | otro"),
|
||||
}, nil)),
|
||||
tool("crear_cotizacion", "Genera el PDF de una cotización para un cliente, a partir de la plantilla activa y los items con precio ya calculado (consulta listar_tarifas antes para saber los valores).",
|
||||
obj(map[string]agentToolParam{
|
||||
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
|
||||
"alcance": str("Descripción del alcance del proyecto o servicio a cotizar"),
|
||||
"tipo_proyecto": str("Tipo de proyecto, ej: migracion_m365, vm_azure, soporte"),
|
||||
"items": agentToolParam{
|
||||
Type: "array",
|
||||
Description: "Ítems de la cotización con precio ya calculado",
|
||||
Items: &agentToolParam{
|
||||
Type: "object",
|
||||
Properties: map[string]agentToolParam{
|
||||
"descripcion": str("Descripción del ítem"),
|
||||
"cantidad": num("Cantidad"),
|
||||
"valor_unitario": num("Valor unitario en COP"),
|
||||
"unidad": str("hora | mes | unico"),
|
||||
},
|
||||
Required: []string{"descripcion", "cantidad", "valor_unitario"},
|
||||
},
|
||||
},
|
||||
}, []string{"cliente_id", "alcance", "items"})),
|
||||
|
||||
// ── Automatización IA: arquitecturas ────────────────────────────────────
|
||||
tool("listar_arquitecturas_referencia", "Lista los patrones de arquitectura técnica ya resueltos (ej: AD redundante en Azure con VPN Gateway) para reutilizarlos en vez de improvisar.",
|
||||
obj(map[string]agentToolParam{"search": str("Búsqueda por nombre o tag")}, nil)),
|
||||
tool("generar_arquitectura", "Genera el PDF de una propuesta técnica ya redactada (consulta antes listar_arquitecturas_referencia para reutilizar patrones existentes).",
|
||||
obj(map[string]agentToolParam{
|
||||
"requerimiento": str("Requerimiento del cliente en texto libre"),
|
||||
"propuesta": str("Propuesta técnica ya redactada (HTML o texto) que se insertará en el documento"),
|
||||
"nombre": str("Título de la propuesta"),
|
||||
"cliente_id": num("ID del cliente (opcional)"),
|
||||
"guardar_como_referencia": agentToolParam{Type: "boolean", Description: "Si true, guarda esta propuesta como patrón reutilizable para el futuro"},
|
||||
}, []string{"requerimiento", "propuesta"})),
|
||||
|
||||
// ── Proyectos ────────────────────────────────────────────────────────
|
||||
tool("listar_proyectos", "Lista proyectos.",
|
||||
obj(map[string]agentToolParam{
|
||||
"page": num("Página"),
|
||||
"search": str("Búsqueda"),
|
||||
}, nil)),
|
||||
tool("crear_proyecto", "Crea un nuevo proyecto para un cliente.",
|
||||
obj(map[string]agentToolParam{
|
||||
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
|
||||
"nombre": str("Nombre del proyecto"),
|
||||
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
|
||||
"descripcion": str("Descripción adicional del proyecto"),
|
||||
}, []string{"cliente_id", "nombre"})),
|
||||
|
||||
// ── Tickets ──────────────────────────────────────────────────────────
|
||||
tool("listar_tickets", "Lista tickets de soporte de todos los proyectos.",
|
||||
@@ -294,9 +359,9 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
names[i] = fmt.Sprintf("#%d %s (%s)", c.ID, c.Nombre, c.BaseURL)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"sistema": "U-Site Admin",
|
||||
"sistema": "U-Site Admin",
|
||||
"coolify_instancias": names,
|
||||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
|
||||
// ── Coolify ────────────────────────────────────────────────────────────
|
||||
@@ -328,9 +393,9 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configID)
|
||||
}
|
||||
endpoint := map[string]string{
|
||||
"coolify_apps": "/applications",
|
||||
"coolify_servicios": "/services",
|
||||
"coolify_servidores": "/servers",
|
||||
"coolify_apps": "/applications",
|
||||
"coolify_servicios": "/services",
|
||||
"coolify_servidores": "/servers",
|
||||
}[name]
|
||||
if endpoint == "" {
|
||||
uuid := getStr("uuid")
|
||||
@@ -464,6 +529,51 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
"cliente_id": contrato.ClienteID,
|
||||
}, nil
|
||||
|
||||
case "crear_contrato":
|
||||
idsRaw, _ := a["servicio_ids"].([]interface{})
|
||||
servicioIDs := make([]uint, 0, len(idsRaw))
|
||||
for _, v := range idsRaw {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
servicioIDs = append(servicioIDs, uint(x))
|
||||
case int:
|
||||
servicioIDs = append(servicioIDs, uint(x))
|
||||
}
|
||||
}
|
||||
contrato, doc, err := CrearContratoConDocumento(
|
||||
uint(getInt("cliente_id", 0)),
|
||||
servicioIDs,
|
||||
getInt("duracion_meses", 12),
|
||||
float64(getInt("precio_acordado", 0)),
|
||||
getStr("moneda"),
|
||||
getStr("notas"),
|
||||
"telegram",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]interface{}{"ok": true, "contrato_id": contrato.ID}
|
||||
if doc != nil {
|
||||
result["documento_id"] = doc.ID
|
||||
result["descargar"] = fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID)
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "generar_documento_contrato":
|
||||
id := uint(getInt("id", 0))
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("id requerido")
|
||||
}
|
||||
doc, err := GenerarDocumentoContrato(id, "telegram")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"documento_id": doc.ID,
|
||||
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
|
||||
}, nil
|
||||
|
||||
// ── Contabilidad ───────────────────────────────────────────────────────
|
||||
case "dashboard_contabilidad":
|
||||
now := time.Now()
|
||||
@@ -511,6 +621,40 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||
|
||||
case "crear_cuenta_cobro":
|
||||
cc, doc, err := CrearCuentaCobroConDocumento(
|
||||
uint(getInt("cliente_id", 0)),
|
||||
getStr("descripcion"),
|
||||
float64(getInt("valor", 0)),
|
||||
nil,
|
||||
getStr("notas"),
|
||||
"telegram",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]interface{}{"ok": true, "cuenta_cobro_id": cc.ID}
|
||||
if doc != nil {
|
||||
result["documento_id"] = doc.ID
|
||||
result["descargar"] = fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID)
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "generar_documento_cuenta_cobro":
|
||||
id := uint(getInt("id", 0))
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("id requerido")
|
||||
}
|
||||
doc, err := GenerarDocumentoCuentaCobro(id, "telegram")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"documento_id": doc.ID,
|
||||
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
|
||||
}, nil
|
||||
|
||||
case "listar_cuentas_pagar":
|
||||
page := getInt("page", 1)
|
||||
estado := getStr("estado")
|
||||
@@ -522,6 +666,77 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||
|
||||
// ── Automatización IA: cotizaciones y tarifas ───────────────────────────
|
||||
case "listar_tarifas":
|
||||
items, err := models.GetTarifasActivas(getStr("categoria"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
|
||||
case "crear_cotizacion":
|
||||
itemsRaw, _ := a["items"].([]interface{})
|
||||
items := make([]ItemCotizacion, 0, len(itemsRaw))
|
||||
for _, raw := range itemsRaw {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
toFloat := func(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case int:
|
||||
return float64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
toStr := func(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
items = append(items, ItemCotizacion{
|
||||
Descripcion: toStr(m["descripcion"]),
|
||||
Cantidad: toFloat(m["cantidad"]),
|
||||
ValorUnitario: toFloat(m["valor_unitario"]),
|
||||
Unidad: toStr(m["unidad"]),
|
||||
})
|
||||
}
|
||||
doc, total, err := CrearCotizacion(uint(getInt("cliente_id", 0)), getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"documento_id": doc.ID,
|
||||
"total": total,
|
||||
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
|
||||
}, nil
|
||||
|
||||
// ── Automatización IA: arquitecturas ────────────────────────────────────
|
||||
case "listar_arquitecturas_referencia":
|
||||
items, _, err := models.GetAllArquitecturas(50, 0, getStr("search"), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
|
||||
case "generar_arquitectura":
|
||||
var clienteID *uint
|
||||
if id := getInt("cliente_id", 0); id > 0 {
|
||||
u := uint(id)
|
||||
clienteID = &u
|
||||
}
|
||||
doc, err := GenerarArquitectura(getStr("requerimiento"), getStr("propuesta"), getStr("nombre"), clienteID, getBool("guardar_como_referencia"), "telegram")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"documento_id": doc.ID,
|
||||
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
|
||||
}, nil
|
||||
|
||||
// ── Proyectos ──────────────────────────────────────────────────────────
|
||||
case "listar_proyectos":
|
||||
page := getInt("page", 1)
|
||||
@@ -534,6 +749,13 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||
|
||||
case "crear_proyecto":
|
||||
p, err := CrearProyectoSimple(uint(getInt("cliente_id", 0)), getStr("nombre"), getStr("stack"), getStr("descripcion"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "id": p.ID, "slug": p.Slug}, nil
|
||||
|
||||
// ── Tickets ────────────────────────────────────────────────────────────
|
||||
case "listar_tickets":
|
||||
estado := getStr("estado")
|
||||
@@ -699,10 +921,10 @@ type anthropicMsg struct {
|
||||
}
|
||||
|
||||
type anthropicReq struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMsg `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMsg `json:"messages"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user