feat(plantillas): subir el documento y que la IA lo devuelva como plantilla editable
Hasta ahora crear una plantilla era escribir HTML con variables de Go a mano. Ahora se sube el documento que ya existe (.docx, .html, .txt o una foto del papel), se lee —docx con stdlib, imágenes por OCR— y la IA lo devuelve armado como plantilla con las variables del generador puestas donde iban los datos. No se guarda solo: el HTML cae en el editor para revisarlo, y si la IA devolvió sintaxis de plantilla inválida se avisa antes de guardar. PDF queda afuera por ahora; el mensaje de error dice qué hacer en su lugar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8c3ab79e88
commit
e681f41639
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// CompletarTextoIA hace una llamada simple (sin streaming, sin tools) al
|
||||
// proveedor configurado para el servicio dado y devuelve el texto de la
|
||||
// respuesta. Es la contraparte no-streaming de GeneraTextoStream, para los
|
||||
// casos en que el backend necesita el resultado completo antes de seguir.
|
||||
func CompletarTextoIA(servicio, sistema, usuario string) (string, error) {
|
||||
config, err := models.GetAiConfigForService(servicio)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no hay configuración de IA activa para %q; configurá una en /app/ai-config", servicio)
|
||||
}
|
||||
modelo := config.ModelName
|
||||
if modelo == "" {
|
||||
return "", fmt.Errorf("la configuración de IA de %q no tiene modelo definido", servicio)
|
||||
}
|
||||
|
||||
provider := strings.ToLower(config.Provider)
|
||||
clave := config.ClaveEnClaro()
|
||||
baseURL := strings.TrimRight(config.BaseURL, "/")
|
||||
|
||||
var endpoint string
|
||||
var cuerpo []byte
|
||||
if provider == "gemini" {
|
||||
endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelo, clave)
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"parts": []map[string]string{{"text": sistema + "\n\n" + usuario}}},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
endpoint = strings.TrimSuffix(baseURL, "/v1") + "/v1/chat/completions"
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"model": modelo,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": sistema},
|
||||
{"role": "user", "content": usuario},
|
||||
},
|
||||
"stream": false,
|
||||
})
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(cuerpo))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if provider != "gemini" && clave != "" {
|
||||
if provider == "ollama" && clave != "ollama" {
|
||||
req.SetBasicAuth("ollama", clave)
|
||||
} else if provider != "ollama" {
|
||||
req.Header.Set("Authorization", "Bearer "+clave)
|
||||
}
|
||||
}
|
||||
|
||||
// Generar una plantilla entera es lento; el timeout es alto a propósito.
|
||||
resp, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no se pudo conectar al proveedor de IA: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("el proveedor de IA respondió %d: %s", resp.StatusCode, recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
|
||||
if provider == "gemini" {
|
||||
var out struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Choices[0].Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// variablesPorTipo documenta, para la IA, qué campos recibe cada plantilla al
|
||||
// renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador
|
||||
// (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service).
|
||||
var variablesPorTipo = map[string]string{
|
||||
"cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
|
||||
{{.Alcance}}, {{.TipoProyecto}}, {{.Total}},
|
||||
{{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`,
|
||||
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
|
||||
"acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`,
|
||||
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
|
||||
}
|
||||
|
||||
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
|
||||
|
||||
// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como
|
||||
// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML
|
||||
// adentro (stdlib alcanza) y las imágenes pasan por OCR.
|
||||
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(nombreArchivo))
|
||||
switch ext {
|
||||
case ".html", ".htm", ".txt", ".md":
|
||||
return string(datos), nil
|
||||
case ".docx":
|
||||
return textoDeDocx(datos)
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
|
||||
mime := "image/png"
|
||||
if ext == ".jpg" || ext == ".jpeg" {
|
||||
mime = "image/jpeg"
|
||||
} else if ext != ".png" {
|
||||
mime = "image/" + strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
|
||||
return ExtraerTextoOCR(0, datos, mime)
|
||||
case ".pdf":
|
||||
return "", fmt.Errorf("el PDF todavía no se puede leer acá; exportalo a .docx o subí una captura de pantalla del documento")
|
||||
default:
|
||||
return "", fmt.Errorf("formato %s no soportado: subí .docx, .html, .txt o una imagen del documento", ext)
|
||||
}
|
||||
}
|
||||
|
||||
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
|
||||
// conservar el formato: la IA solo necesita el contenido y el orden.
|
||||
func textoDeDocx(datos []byte) (string, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := string(xmlBytes)
|
||||
// Un párrafo y un salto de línea explícito valen como salto de línea;
|
||||
// el resto de las etiquetas se descarta.
|
||||
s = strings.ReplaceAll(s, "</w:p>", "\n")
|
||||
s = strings.ReplaceAll(s, "<w:br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tr>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tc>", "\t")
|
||||
s = etiquetaXML.ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").Replace(s)
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
|
||||
}
|
||||
|
||||
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
|
||||
// HTML con las variables Go que usa el generador. Lo que devuelve va al editor
|
||||
// para que el admin lo revise antes de guardar: no se guarda solo.
|
||||
func ConvertirEnPlantilla(tipo, textoDocumento string) (string, error) {
|
||||
if strings.TrimSpace(textoDocumento) == "" {
|
||||
return "", fmt.Errorf("no se pudo leer texto del archivo")
|
||||
}
|
||||
if len(textoDocumento) > 20000 {
|
||||
textoDocumento = textoDocumento[:20000]
|
||||
}
|
||||
vars := variablesPorTipo[tipo]
|
||||
if vars == "" {
|
||||
vars = variablesComunes
|
||||
}
|
||||
|
||||
sistema := `Sos un asistente que convierte documentos en plantillas HTML para Go text/template.
|
||||
Reglas:
|
||||
- Devolvé SOLO el HTML de la plantilla, sin explicaciones y sin bloques de código markdown.
|
||||
- Reemplazá los datos concretos del documento (nombres, NITs, fechas, montos, ítems) por las variables de la lista. Lo que sea texto fijo del formato se deja tal cual.
|
||||
- Si el documento tiene una tabla de ítems, usá {{range .Items}}…{{end}} para las filas.
|
||||
- Usá estilos inline (style="…"), sin CSS externo ni <script>: el HTML se convierte a PDF.
|
||||
- No inventes variables que no estén en la lista.`
|
||||
|
||||
usuario := fmt.Sprintf(`Tipo de documento: %s
|
||||
|
||||
Variables disponibles:
|
||||
%s
|
||||
%s
|
||||
|
||||
Documento de referencia:
|
||||
---
|
||||
%s
|
||||
---`, tipo, variablesComunes, vars, textoDocumento)
|
||||
|
||||
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return limpiarCercaDeCodigo(salida), nil
|
||||
}
|
||||
|
||||
// limpiarCercaDeCodigo saca el ```html … ``` que los modelos agregan aunque se
|
||||
// les pida que no lo hagan.
|
||||
func limpiarCercaDeCodigo(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
if i := strings.Index(s, "\n"); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(s, "```"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func docxDePrueba(t *testing.T, documentXML string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(documentXML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtraerTextoDePlantillaDocx(t *testing.T) {
|
||||
xml := `<?xml version="1.0"?><w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>COTIZACI&Oacute;N</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Cliente: </w:t></w:r><w:r><w:t>Acme SAS</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Total: $1.500.000</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
|
||||
got, err := ExtraerTextoDePlantilla("cotizacion.docx", docxDePrueba(t, xml))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtraerTextoDePlantilla: %v", err)
|
||||
}
|
||||
// Los runs de un mismo párrafo quedan pegados; los párrafos, en líneas.
|
||||
if !strings.Contains(got, "Cliente: Acme SAS") {
|
||||
t.Errorf("falta la línea del cliente en:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Total: $1.500.000") {
|
||||
t.Errorf("falta el total en:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "<w:") {
|
||||
t.Errorf("quedaron etiquetas XML en:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.pdf", []byte("x")); err == nil {
|
||||
t.Error("el PDF debería devolver error explicando la alternativa")
|
||||
}
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
|
||||
t.Error("una extensión desconocida debería devolver error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimpiarCercaDeCodigo(t *testing.T) {
|
||||
casos := map[string]string{
|
||||
"```html\n<p>hola</p>\n```": "<p>hola</p>",
|
||||
"```\n<p>hola</p>\n```": "<p>hola</p>",
|
||||
"<p>hola</p>": "<p>hola</p>",
|
||||
}
|
||||
for in, want := range casos {
|
||||
if got := limpiarCercaDeCodigo(in); got != want {
|
||||
t.Errorf("limpiarCercaDeCodigo(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,24 @@
|
||||
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
|
||||
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
|
||||
<p class="text-xs text-gray-500 mb-2">
|
||||
Subí el archivo (.docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
|
||||
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input type="file" x-ref="archivo" accept=".docx,.html,.htm,.txt,.md,image/*"
|
||||
class="text-xs" />
|
||||
<button type="button" @click="importar()" :disabled="importando"
|
||||
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
|
||||
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
|
||||
</button>
|
||||
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
|
||||
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||
<div>
|
||||
@@ -154,6 +172,7 @@ document.addEventListener('alpine:init', () => {
|
||||
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
|
||||
addModal: false, editModal: false, deleteModal: false,
|
||||
selectedId: null, templateError: '',
|
||||
importando: false, avisoImport: '', errorImport: false,
|
||||
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
|
||||
toast: { show:false, msg:'', type:'ok' },
|
||||
|
||||
@@ -179,6 +198,26 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||
|
||||
async importar() {
|
||||
const f = this.$refs.archivo?.files?.[0];
|
||||
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
|
||||
this.importando = true; this.avisoImport = ''; this.errorImport = false;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('archivo', f);
|
||||
fd.append('tipo', this.form.tipo);
|
||||
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
|
||||
this.form.contenido_html = data.contenido_html || '';
|
||||
if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, '');
|
||||
this.errorImport = !!data.aviso;
|
||||
this.avisoImport = data.aviso || 'Listo, revisá el HTML abajo';
|
||||
} catch(e) {
|
||||
this.errorImport = true;
|
||||
this.avisoImport = e.response?.data?.error || e.message;
|
||||
}
|
||||
this.importando = false;
|
||||
},
|
||||
|
||||
async validar() {
|
||||
this.templateError = '';
|
||||
},
|
||||
@@ -187,6 +226,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.addModal = this.editModal = this.deleteModal = false;
|
||||
this.selectedId = null;
|
||||
this.templateError = '';
|
||||
this.avisoImport = ''; this.errorImport = false;
|
||||
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
|
||||
},
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"text/template"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
|
||||
@@ -103,6 +105,48 @@ func DeletePlantillaDocumento(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||
}
|
||||
|
||||
// ImportarPlantillaDocumento recibe un documento (docx, html, txt o imagen), lo
|
||||
// lee y le pide a la IA que lo devuelva como plantilla HTML con las variables
|
||||
// del generador. NO guarda nada: el HTML vuelve al editor para revisarlo.
|
||||
// POST /app/api/plantillas-documento/importar (multipart: archivo, tipo)
|
||||
func ImportarPlantillaDocumento(c *fiber.Ctx) error {
|
||||
archivo, err := c.FormFile("archivo")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Subí un archivo en el campo 'archivo'"})
|
||||
}
|
||||
if archivo.Size > 10<<20 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "El archivo supera los 10 MB"})
|
||||
}
|
||||
f, err := archivo.Open()
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||
}
|
||||
defer f.Close()
|
||||
datos, err := io.ReadAll(io.LimitReader(f, 10<<20))
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
texto, err := services.ExtraerTextoDePlantilla(archivo.Filename, datos)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
tipo := c.FormValue("tipo", "cotizacion")
|
||||
html, err := services.ConvertirEnPlantilla(tipo, texto)
|
||||
if err != nil {
|
||||
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Si la IA devolvió algo que no compila, es mejor decirlo acá que al guardar.
|
||||
if _, err := template.New("validate").Parse(html); err != nil {
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"contenido_html": html,
|
||||
"aviso": "La IA devolvió HTML con sintaxis de plantilla inválida (" + err.Error() + "). Revisalo antes de guardar.",
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"contenido_html": html})
|
||||
}
|
||||
|
||||
// ─── Tarifas ────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetTarifas(c *fiber.Ctx) error {
|
||||
|
||||
@@ -85,6 +85,7 @@ func RenovacionesRoutes(protected fiber.Router) {
|
||||
|
||||
// ─── Automatización IA: Plantillas de documento ────────────────────
|
||||
protected.Get("/automatizacion/plantillas", middlewares.MenuMiddleware, controllers.PlantillasDocumentoView)
|
||||
protected.Post("/api/plantillas-documento/importar", controllers.ImportarPlantillaDocumento)
|
||||
protected.Get("/api/plantillas-documento", controllers.GetPlantillasDocumento)
|
||||
protected.Get("/api/plantillas-documento/:id", controllers.GetPlantillaDocumento)
|
||||
protected.Post("/api/plantillas-documento", controllers.CreatePlantillaDocumento)
|
||||
|
||||
Reference in New Issue
Block a user