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:
Lizandro GD
2026-08-03 00:45:58 +00:00
co-authored by Claude Sonnet 5
parent 772a4a5656
commit 516220a8a1
32 changed files with 2272 additions and 132 deletions
+14 -3
View File
@@ -24,11 +24,11 @@ type AiConfig struct {
// "" = global (disponible para todos como fallback)
// "landing" = exclusivo para Landing Generator
// "query_runner" = exclusivo para Query Runner SQL
Modulo string `gorm:"size:50;default:''" json:"modulo"`
Modulo string `gorm:"size:50;default:''" json:"modulo"`
// Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador.
// Solo debe haber una config activa como agente a la vez.
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
}
func (AiConfig) TableName() string { return "ai_configs" }
@@ -127,6 +127,17 @@ func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
return &ai, tg, nil
}
// GetAgenteBotAiConfig retorna solo la config de IA marcada como agente (el mismo
// "cerebro" que usa el bot de Telegram), sin exigir que tenga un bot de Telegram
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err)
}
return &ai, nil
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad:
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
+60
View File
@@ -0,0 +1,60 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Arquitectura guarda patrones de arquitectura técnica ya resueltos (ej: Active
// Directory redundante en Azure con VPN Gateway) para que la IA los reutilice como
// referencia al generar una propuesta técnica nueva, en vez de improvisar cada vez.
type Arquitectura struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // diagrama/detalle en HTML, reutilizable como referencia
Tags string `json:"tags" gorm:"column:tags;size:255"` // comma-separated, ej: "azure,ad,vpn"
EsReferencia bool `json:"es_referencia" gorm:"column:es_referencia;default:true"` // true = patrón reutilizable, false = propuesta generada puntual
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
}
func (Arquitectura) TableName() string { return "arquitecturas" }
func GetAllArquitecturas(limit, offset int, search string, soloReferencias bool) ([]Arquitectura, int64, error) {
var items []Arquitectura
var total int64
db := app.Http.Database.DB.Model(&Arquitectura{})
if search != "" {
db = db.Where("nombre ILIKE ? OR tags ILIKE ?", "%"+search+"%", "%"+search+"%")
}
if soloReferencias {
db = db.Where("es_referencia = ?", true)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetArquitecturaByID(id uint) (*Arquitectura, error) {
var item Arquitectura
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateArquitectura(a *Arquitectura) error {
return app.Http.Database.DB.Create(a).Error
}
func UpdateArquitectura(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Arquitectura{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteArquitectura(id uint) error {
return app.Http.Database.DB.Delete(&Arquitectura{}, id).Error
}
+93 -66
View File
@@ -61,19 +61,19 @@ func (Transaccion) TableName() string { return "contab_transacciones" }
type CuentaCobro struct {
gorm.Model
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
}
func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
@@ -82,17 +82,17 @@ func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
type CuentaPagar struct {
gorm.Model
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
}
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
@@ -101,12 +101,12 @@ func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
type ConsolidadoMensual struct {
gorm.Model
Anio int `json:"anio" gorm:"column:anio;not null"`
Mes int `json:"mes" gorm:"column:mes;not null"`
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
Anio int `json:"anio" gorm:"column:anio;not null"`
Mes int `json:"mes" gorm:"column:mes;not null"`
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
TotalRetenciones float64 `json:"total_retenciones" gorm:"column:total_retenciones;default:0"`
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
}
func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
@@ -307,7 +307,11 @@ func SeedBalanceData() {
}
// ─── FACTURAS → Transacciones (ingresos) ────────────────────────────────
facturas := []struct{ factura float64; valor float64; fecha string }{
facturas := []struct {
factura float64
valor float64
fecha string
}{
{54, 450000, "2026-01-01 00:00:00"},
{55, 9000000, "2026-01-01 00:00:00"},
{56, 119000, "2026-01-01 00:00:00"},
@@ -347,7 +351,7 @@ func SeedBalanceData() {
if db.Where("descripcion = ?", "Ajuste/NC ene 2026").First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
Valor: 130000, CuentaID: cuentaIng, Estado: "registrada",
})
}
@@ -369,7 +373,11 @@ func SeedBalanceData() {
}
// ─── IVA → Transacciones (egresos) ──────────────────────────────────────
ivas := []struct{ valor float64; entidad string; desc string }{
ivas := []struct {
valor float64
entidad string
desc string
}{
{285000, "DOCUXER", "IVA Fact #63"},
{503500, "DOCUXER", "IVA Fact #70"},
{19000, "GIAF SAS", "IVA Fact #56"},
@@ -383,7 +391,7 @@ func SeedBalanceData() {
eid := getEntidad(iv.entidad)
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: iv.desc,
Tipo: "egreso", Descripcion: iv.desc,
Valor: iv.valor, CuentaID: cuentaImp,
EntidadID: eid, Estado: "registrada",
})
@@ -391,7 +399,11 @@ func SeedBalanceData() {
}
// ─── RETENCION → Transacciones (egresos) ────────────────────────────────
rets := []struct{ valor float64; fecha string; estado string }{
rets := []struct {
valor float64
fecha string
estado string
}{
{195000, "2026-03-01 00:00:00", "pagado"},
{257000, "2026-02-01 00:00:00", "pagado"},
{431000, "2026-01-01 00:00:00", "pendiente"},
@@ -402,7 +414,7 @@ func SeedBalanceData() {
if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate(r.fecha),
Tipo: "egreso", Descripcion: desc,
Tipo: "egreso", Descripcion: desc,
Valor: r.valor, CuentaID: cuentaImp,
Estado: "registrada",
})
@@ -410,7 +422,11 @@ func SeedBalanceData() {
}
// ─── CUENTAS DE COBRO (las que te pasan a ti) → CuentasPagar ────────────
cobros := []struct{ entidad string; valor float64; fecha string }{
cobros := []struct {
entidad string
valor float64
fecha string
}{
{"NATALIA", 2000000, "2026-01-01 00:00:00"},
{"FELIPE", 2000000, "2026-01-01 00:00:00"},
{"CONTADORA", 1780000, "2026-01-01 00:00:00"},
@@ -439,7 +455,10 @@ func SeedBalanceData() {
}
// ─── CONSOLIDADO POR MES ───────────────────────────────────────────────
consols := []struct{ mes int; ing, egre, resul float64 }{
consols := []struct {
mes int
ing, egre, resul float64
}{
{1, 12669000, 8467849, 4201151},
{2, 8470500, 4300000, 4170500},
{3, 2109000, 4300000, -2191000},
@@ -459,6 +478,14 @@ func SeedBalanceData() {
log.Println("[SEED] Balance data imported from BALANCE.numbers")
}
func GetCuentaCobroByID(id uint) (*CuentaCobro, error) {
var item CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").Preload("Entidad").First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Create(cc).Error
}
@@ -565,12 +592,12 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
resultado := ingresos.Total - egresos.Total
c := &ConsolidadoMensual{
Anio: anio,
Mes: mes,
TotalIngresos: ingresos.Total,
TotalEgresos: egresos.Total,
Anio: anio,
Mes: mes,
TotalIngresos: ingresos.Total,
TotalEgresos: egresos.Total,
TotalRetenciones: 0,
Resultado: resultado,
Resultado: resultado,
}
var existing ConsolidadoMensual
@@ -605,15 +632,15 @@ func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
// ─── Datos para dashboard ────────────────────────────────────────────────────
type DashboardData struct {
Mes int `json:"mes"`
Anio int `json:"anio"`
TotalIngresos float64 `json:"total_ingresos"`
TotalEgresos float64 `json:"total_egresos"`
Resultado float64 `json:"resultado"`
CantTransacciones int64 `json:"cant_transacciones"`
PendientesCobro float64 `json:"pendientes_cobro"`
PendientesPago float64 `json:"pendientes_pago"`
Transacciones []Transaccion `json:"transacciones"`
Mes int `json:"mes"`
Anio int `json:"anio"`
TotalIngresos float64 `json:"total_ingresos"`
TotalEgresos float64 `json:"total_egresos"`
Resultado float64 `json:"resultado"`
CantTransacciones int64 `json:"cant_transacciones"`
PendientesCobro float64 `json:"pendientes_cobro"`
PendientesPago float64 `json:"pendientes_pago"`
Transacciones []Transaccion `json:"transacciones"`
}
func GetDashboardData(mes, anio int) (*DashboardData, error) {
@@ -657,15 +684,15 @@ func GetDashboardData(mes, anio int) (*DashboardData, error) {
func SeedContabilidad() {
cuentas := []Cuenta{
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
{Codigo: "EGR-GRAL", Nombre: "Gastos generales", Tipo: "egreso", Color: "#eab308"},
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
}
for _, c := range cuentas {
var existing Cuenta
@@ -675,13 +702,13 @@ func SeedContabilidad() {
}
entidades := []Entidad{
{Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "GIAF SAS", Tipo: "cliente"},
{Nombre: "TECZONE", Tipo: "proveedor"},
{Nombre: "FELIPE", Tipo: "proveedor"},
{Nombre: "NATALIA", Tipo: "proveedor"},
{Nombre: "CONTADORA", Tipo: "proveedor"},
{Nombre: "ANDREMER", Tipo: "proveedor"},
{Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "GIAF SAS", Tipo: "cliente"},
{Nombre: "TECZONE", Tipo: "proveedor"},
{Nombre: "FELIPE", Tipo: "proveedor"},
{Nombre: "NATALIA", Tipo: "proveedor"},
{Nombre: "CONTADORA", Tipo: "proveedor"},
{Nombre: "ANDREMER", Tipo: "proveedor"},
}
for _, e := range entidades {
var existing Entidad
+57
View File
@@ -0,0 +1,57 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// DocumentoGenerado es el historial de documentos producidos por el motor de
// automatización con IA (cotización, contrato, acta/arquitectura, cuenta de cobro),
// sin importar el canal que los originó (Telegram, chat propio o Claude directo).
type DocumentoGenerado struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PlantillaID *uint `json:"plantilla_id" gorm:"column:plantilla_id"`
Nombre string `json:"nombre" gorm:"column:nombre;size:200"`
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta en disco, ej: uploads/documentos/cotizacion/12/xyz.pdf
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;default:'application/pdf'"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
DatosJSON string `json:"datos_json" gorm:"column:datos_json;type:text"` // input usado para generarlo (auditoría)
GeneradoPor string `json:"generado_por" gorm:"column:generado_por;size:20;default:'web'"` // web | telegram | claude_api
}
func (DocumentoGenerado) TableName() string { return "documentos_generados" }
func CreateDocumentoGenerado(d *DocumentoGenerado) error {
return app.Http.Database.DB.Create(d).Error
}
func GetAllDocumentosGenerados(limit, offset int, tipo string, clienteID uint) ([]DocumentoGenerado, int64, error) {
var items []DocumentoGenerado
var total int64
db := app.Http.Database.DB.Model(&DocumentoGenerado{}).Preload("Cliente")
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if clienteID != 0 {
db = db.Where("cliente_id = ?", clienteID)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetDocumentoGeneradoByID(id uint) (*DocumentoGenerado, error) {
var item DocumentoGenerado
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
+74
View File
@@ -0,0 +1,74 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// PlantillaDocumento es la fuente de verdad de las plantillas base usadas por
// la automatización con IA: cotización, contrato, acta de proyecto y cuenta de cobro.
// Editar una plantilla aquí actualiza automáticamente todos los canales (Telegram,
// chat propio, Claude directo) que generan ese tipo de documento.
type PlantillaDocumento struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // Go text/template
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"`
}
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
func GetAllPlantillasDocumento(limit, offset int, search, tipo string) ([]PlantillaDocumento, int64, error) {
var items []PlantillaDocumento
var total int64
db := app.Http.Database.DB.Model(&PlantillaDocumento{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("tipo ASC, version DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.
Where("tipo = ? AND activa = ?", tipo, true).
Order("version DESC").
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreatePlantillaDocumento(p PlantillaDocumento) error {
return app.Http.Database.DB.Create(&p).Error
}
func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&PlantillaDocumento{}).Where("id = ?", id).Updates(updates).Error
}
func DeletePlantillaDocumento(id uint) error {
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
}
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Tarifa es la tabla de precios/reglas de negocio que la IA usa para calcular
// cotizaciones y contratos: valor por hora según tipo de servicio, licencias M365,
// tipos de VM Azure recurrentes, márgenes estándar, etc.
type Tarifa struct {
gorm.Model
Categoria string `json:"categoria" gorm:"column:categoria;size:50;not null;index"` // hora_servicio | licencia | vm_azure | margen | otro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Moneda string `json:"moneda" gorm:"column:moneda;size:10;default:'COP'"`
Unidad string `json:"unidad" gorm:"column:unidad;size:20"` // hora | mes | unico | porcentaje
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (Tarifa) TableName() string { return "tarifas" }
func GetAllTarifas(limit, offset int, search, categoria string) ([]Tarifa, int64, error) {
var items []Tarifa
var total int64
db := app.Http.Database.DB.Model(&Tarifa{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("categoria ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// GetTarifasActivas retorna todas las tarifas activas, opcionalmente filtradas por categoría.
// Es lo que usan los endpoints de generación para calcular precios.
func GetTarifasActivas(categoria string) ([]Tarifa, error) {
var items []Tarifa
db := app.Http.Database.DB.Where("activo = ?", true)
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func GetTarifaByID(id uint) (*Tarifa, error) {
var item Tarifa
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateTarifa(t Tarifa) error {
return app.Http.Database.DB.Create(&t).Error
}
func UpdateTarifa(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Tarifa{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteTarifa(id uint) error {
return app.Http.Database.DB.Delete(&Tarifa{}, id).Error
}
+57
View File
@@ -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
}
+136
View File
@@ -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)
}
+82
View File
@@ -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
}
+52
View File
@@ -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
}
+231 -9
View File
@@ -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"`
}