Files
soft_usite/pkg/services/documento_service.go
T
Lizandro GDandClaude Sonnet 5 516220a8a1 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>
2026-08-03 00:45:58 +00:00

137 lines
4.2 KiB
Go

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)
}