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