diff --git a/Dockerfile b/Dockerfile index 6025c29..c7f2cd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Los assets del frontend (public/) se compilan localmente # antes del push con: npm run production # ────────────────────────────────────────── -FROM golang:1.25-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app # Instalar dependencias del sistema necesarias para CGO (argon2, etc.) @@ -29,8 +29,10 @@ RUN cd agent && go mod download && \ FROM alpine:3.20 WORKDIR /app -# Certificados TLS y timezone -RUN apk add --no-cache ca-certificates tzdata +# Certificados TLS, timezone y Chromium headless (motor de generación de PDF +# para cotizaciones, contratos, actas y cuentas de cobro — ver pkg/services/pdf_service.go) +RUN apk add --no-cache ca-certificates tzdata chromium +ENV CHROME_EXEC_PATH=/usr/bin/chromium-browser # Copiar binario compilado COPY --from=builder /app/apiv2 . diff --git a/go.mod b/go.mod index 6dce350..b599d3e 100755 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sujit-baniya/fiber-boilerplate -go 1.25.0 +go 1.26 require ( github.com/BurntSushi/toml v1.6.0 // indirect @@ -25,12 +25,11 @@ require ( github.com/pyroscope-io/pyroscope v0.37.2 github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/sujit-baniya/flash v0.1.9 - github.com/sujit-baniya/ip v0.0.10 github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/valyala/bytebufferpool v1.0.0 github.com/xhit/go-simple-mail/v2 v2.16.0 golang.org/x/crypto v0.51.0 - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gorm.io/driver/mysql v1.5.7 @@ -43,6 +42,8 @@ require ( require ( github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/chai2010/webp v1.4.0 + github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe + github.com/chromedp/chromedp v0.16.0 github.com/go-sql-driver/mysql v1.8.1 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 @@ -66,6 +67,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/casbin/govaluate v1.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -73,12 +75,16 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-test/deep v1.1.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect @@ -116,7 +122,6 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oschwald/maxminddb-golang v1.13.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index 2dc061a..1ed9ca8 100755 --- a/go.sum +++ b/go.sum @@ -95,8 +95,6 @@ github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6Ct github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= @@ -137,6 +135,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= +github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe h1:PmhRwLZ8qLtldQCBiydwdPFJI8WVQ936ux1cpgHLRb8= +github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -206,6 +210,8 @@ github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7b github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -236,9 +242,14 @@ github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o= github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v2 v2.1.0/go.mod h1:aG+lMkwy3LyVit4CnmYUbUdgjpc3UYOltvlJZ78rgQ0= -github.com/gofiber/fiber/v2 v2.9.0/go.mod h1:Ah3IJikrKNRepl/HuVawppS25X7FWohwfCSRn7kJG28= github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= @@ -439,9 +450,6 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.11.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= @@ -466,6 +474,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -556,9 +566,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= -github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis= -github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= -github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -669,8 +678,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/sujit-baniya/flash v0.1.9 h1:9+g9GrauXF8JH7ivoG4XBjl04r+vNkB9ZFN+5RKTugo= github.com/sujit-baniya/flash v0.1.9/go.mod h1:nNyzxxxU11ctsPe99BhV39kP6/jcfoRDp4T7P5m4K4E= -github.com/sujit-baniya/ip v0.0.10 h1:4FxdNgibzkDGS6B1+2//o4nStxmvD11i3NEgljh6SLk= -github.com/sujit-baniya/ip v0.0.10/go.mod h1:9wktAv0khHVF2t6lIWRbNV4ScqcHSi92pc5RfBb9YV8= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= @@ -687,8 +694,6 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= -github.com/valyala/fasthttp v1.23.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU= -github.com/valyala/fasthttp v1.24.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU= github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U= github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= @@ -753,7 +758,6 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -847,7 +851,6 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226101413-39120d07d75e/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -924,7 +927,6 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -952,7 +954,6 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -987,9 +988,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/main.go b/main.go index 241bae2..5a70367 100755 --- a/main.go +++ b/main.go @@ -118,6 +118,11 @@ func main() { // Tablero de tareas &models.Tarea{}, &models.TareaComentario{}, + // Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA + &models.PlantillaDocumento{}, + &models.Tarifa{}, + &models.DocumentoGenerado{}, + &models.Arquitectura{}, ) // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() @@ -138,6 +143,7 @@ func main() { migrations.SeedWebSms() migrations.SeedUrlMonitor() migrations.SeedTareas() + migrations.SeedAutomatizacionIA() // Cargar config SMTP desde BD (sobreescribe valores del .env/config.yml si hay registro activo) if smtpCfg, err := models.GetSmtpConfig(); err == nil { app.Http.Mail.Host = smtpCfg.Host diff --git a/migrations/migrate.go b/migrations/migrate.go index 15e0fdd..d9f2eea 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -104,6 +104,11 @@ func Migrate() { // Tablero de tareas &models.Tarea{}, &models.TareaComentario{}, + // Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA + &models.PlantillaDocumento{}, + &models.Tarifa{}, + &models.DocumentoGenerado{}, + &models.Arquitectura{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -150,6 +155,9 @@ func Migrate() { // Agregar submódulo "Partner Recursos" al módulo Portal de Clientes SeedPartnerRecursos() + // Crear módulo y submódulos de Automatización IA (cotizaciones, contratos, asistente) + SeedAutomatizacionIA() + log.Println("Migration Completed...") } @@ -404,6 +412,79 @@ func SeedRenovaciones() { log.Println("[SEED] Seed de Renovaciones completado.") } +// SeedAutomatizacionIA inserta el módulo "Automatización IA" y sus submódulos +// (Asistente, Plantillas de Documento, Tarifas) si no existen, y los asigna al +// rol Administrador. Es idempotente. +func SeedAutomatizacionIA() { + db := app.Http.Database.DB + + // 1. Crear/obtener módulo + var modulo models.Modules + result := db.Where("title = ?", "Automatización IA").First(&modulo) + if result.Error != nil { + modulo = models.Modules{ + Title: "Automatización IA", + Description: "Cotizaciones, contratos, actas y cuentas de cobro generados por IA", + ModifiedAt: time.Now(), + } + if err := db.Create(&modulo).Error; err != nil { + log.Printf("[SEED] Error creando módulo Automatización IA: %v", err) + return + } + log.Printf("[SEED] Módulo 'Automatización IA' creado con ID %d", modulo.ID) + } else { + log.Printf("[SEED] Módulo 'Automatización IA' ya existe (ID %d)", modulo.ID) + } + + // 2. Definir submódulos + entries := []struct{ title, desc, url string }{ + {"Asistente", "Chat propio: mismo motor y tools que el bot de Telegram", "/app/asistente"}, + {"Plantillas de Documento", "Fuente de verdad de cotización, contrato, acta y cuenta de cobro", "/app/automatizacion/plantillas"}, + {"Tarifas", "Valor por hora, licencias, VMs y márgenes usados al cotizar", "/app/automatizacion/tarifas"}, + } + + var insertados []models.Submodules + for _, e := range entries { + var sub models.Submodules + if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: e.title, + Description: e.desc, + Url: e.url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err) + continue + } + log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID) + } else { + if sub.ModuleId != modulo.ID { + if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil { + log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err) + } else { + log.Printf("[SEED] Submódulo '%s' reasignado a módulo Automatización IA", sub.Title) + } + } else { + log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID) + } + } + insertados = append(insertados, sub) + } + + // 3. Asignar solo al rol Administrador + var rolAdmin models.Roles + if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil { + log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) + } else { + db.Model(&rolAdmin).Association("Submodules").Append(&insertados) + log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'") + } + + log.Println("[SEED] Seed de Automatización IA completado.") +} + // SeedIntegraciones crea el módulo "Integraciones" con los submódulos // de Hostinger y Cloudflare, y los asigna a todos los roles existentes. // Es idempotente: si el registro ya existe (por URL) lo reutiliza sin duplicar. diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index 922ee91..fc4f769 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -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) diff --git a/pkg/models/arquitectura.go b/pkg/models/arquitectura.go new file mode 100644 index 0000000..09bb968 --- /dev/null +++ b/pkg/models/arquitectura.go @@ -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 +} diff --git a/pkg/models/contabilidad.go b/pkg/models/contabilidad.go index 36896e9..865f8fa 100644 --- a/pkg/models/contabilidad.go +++ b/pkg/models/contabilidad.go @@ -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 diff --git a/pkg/models/documento_generado.go b/pkg/models/documento_generado.go new file mode 100644 index 0000000..acabcfb --- /dev/null +++ b/pkg/models/documento_generado.go @@ -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 +} diff --git a/pkg/models/plantilla_documento.go b/pkg/models/plantilla_documento.go new file mode 100644 index 0000000..af857d1 --- /dev/null +++ b/pkg/models/plantilla_documento.go @@ -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 +} diff --git a/pkg/models/tarifa.go b/pkg/models/tarifa.go new file mode 100644 index 0000000..adccab5 --- /dev/null +++ b/pkg/models/tarifa.go @@ -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 +} diff --git a/pkg/services/arquitectura_service.go b/pkg/services/arquitectura_service.go new file mode 100644 index 0000000..d47b96a --- /dev/null +++ b/pkg/services/arquitectura_service.go @@ -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 +} diff --git a/pkg/services/contrato_documento_service.go b/pkg/services/contrato_documento_service.go new file mode 100644 index 0000000..c6023c9 --- /dev/null +++ b/pkg/services/contrato_documento_service.go @@ -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 +} diff --git a/pkg/services/cuenta_cobro_documento_service.go b/pkg/services/cuenta_cobro_documento_service.go new file mode 100644 index 0000000..0f58ce5 --- /dev/null +++ b/pkg/services/cuenta_cobro_documento_service.go @@ -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 +} diff --git a/pkg/services/documento_service.go b/pkg/services/documento_service.go new file mode 100644 index 0000000..07ebfb6 --- /dev/null +++ b/pkg/services/documento_service.go @@ -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) +} diff --git a/pkg/services/pdf_service.go b/pkg/services/pdf_service.go new file mode 100644 index 0000000..80a54a7 --- /dev/null +++ b/pkg/services/pdf_service.go @@ -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 +} diff --git a/pkg/services/proyecto_service.go b/pkg/services/proyecto_service.go new file mode 100644 index 0000000..76edfd2 --- /dev/null +++ b/pkg/services/proyecto_service.go @@ -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 +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 7139cf8..47042bb 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -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"` } diff --git a/resources/views/automatizacion/asistente.html b/resources/views/automatizacion/asistente.html new file mode 100644 index 0000000..8f86ce0 --- /dev/null +++ b/resources/views/automatizacion/asistente.html @@ -0,0 +1,80 @@ +
+
+
+

Asistente U-Site

+

Mismo motor y mismas herramientas que el bot de Telegram: cotizaciones, contratos, contabilidad, proyectos, Coolify…

+
+ +
+ +
+ + +
+
Pensando…
+
+
+ +
+ + +
+
+ + diff --git a/resources/views/automatizacion/plantillas_documento.html b/resources/views/automatizacion/plantillas_documento.html new file mode 100644 index 0000000..2c8dd29 --- /dev/null +++ b/resources/views/automatizacion/plantillas_documento.html @@ -0,0 +1,226 @@ +
+
+ Cargando... +
+ +
+
+
+

Plantillas de Documento

+

Fuente de verdad de cotizaciones, contratos, actas y cuentas de cobro generadas por IA

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
NombreTipoVersiónEstado
Sin plantillas
+
+ +
+ Total: +
+ + + +
+
+
+ + +
+
+

+ +
+ Variables comunes: + {{.Cliente}}, + {{.Alcance}}, + {{.Fecha}}, + {{.EmpresaNombre}}, + {{.Total}}, + {{range .Items}}...{{.Descripcion}}...{{end}} + Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec). +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +

+
+
+ + +
+
+
+
+ + +
+
+

¿Eliminar plantilla?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ +
+
+ + diff --git a/resources/views/automatizacion/tarifas.html b/resources/views/automatizacion/tarifas.html new file mode 100644 index 0000000..d9b785d --- /dev/null +++ b/resources/views/automatizacion/tarifas.html @@ -0,0 +1,217 @@ +
+
+ Cargando... +
+ +
+
+
+

Tarifas

+

Valor por hora, licencias, VMs y márgenes usados por la IA al cotizar

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + +
NombreCategoríaValorUnidadEstado
Sin tarifas
+
+ +
+ Total: +
+ + + +
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+

¿Eliminar tarifa?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ +
+
+ + diff --git a/rest/controllers/arquitectura_controller.go b/rest/controllers/arquitectura_controller.go new file mode 100644 index 0000000..1eb3e9a --- /dev/null +++ b/rest/controllers/arquitectura_controller.go @@ -0,0 +1,56 @@ +package controllers + +import ( + "math" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// GetArquitecturas lista los patrones de arquitectura de referencia. +func GetArquitecturas(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllArquitecturas(limit, offset, c.Query("search"), c.Query("solo_referencias") == "true") + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +type crearArquitecturaRequest struct { + Requerimiento string `json:"requerimiento"` + Propuesta string `json:"propuesta"` + Nombre string `json:"nombre"` + ClienteID uint `json:"cliente_id"` + GuardarComoReferencia bool `json:"guardar_como_referencia"` +} + +// CreateArquitectura genera el PDF de una propuesta técnica. +// POST /api/v2/arquitecturas +func CreateArquitectura(c *fiber.Ctx) error { + var req crearArquitecturaRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + var clienteID *uint + if req.ClienteID != 0 { + clienteID = &req.ClienteID + } + doc, err := services.GenerarArquitectura(req.Requerimiento, req.Propuesta, req.Nombre, clienteID, req.GuardarComoReferencia, generadoPorFromContext(c)) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc}) +} diff --git a/rest/controllers/asistente_controller.go b/rest/controllers/asistente_controller.go new file mode 100644 index 0000000..d10cc97 --- /dev/null +++ b/rest/controllers/asistente_controller.go @@ -0,0 +1,74 @@ +package controllers + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// AsistenteView renderiza el chat propio dentro del dashboard: mismo motor y +// mismas tools que el bot de Telegram (services.ProcessAgentMessage), solo que +// accesible directamente desde la web sin pasar por Telegram. +func AsistenteView(c *fiber.Ctx) error { + return c.Render("automatizacion/asistente", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +type asistenteChatRequest struct { + Mensaje string `json:"mensaje"` +} + +// asistenteChatID deriva un chat_id sintético y estable por usuario del dashboard, +// en un rango que nunca colisiona con IDs reales de chats de Telegram (positivos +// < 10^10 para chats privados, negativos para grupos/canales). +func asistenteChatID(c *fiber.Ctx) int64 { + user, _ := c.Locals("user").(map[string]interface{}) + var userID uint + if v, ok := user["ID"]; ok { + switch x := v.(type) { + case uint: + userID = x + case int: + userID = uint(x) + case float64: + userID = uint(x) + } + } + return 9_000_000_000 + int64(userID) +} + +// PostAsistenteChat procesa un mensaje del chat propio del dashboard. +// POST /app/api/asistente/chat +func PostAsistenteChat(c *fiber.Ctx) error { + var req asistenteChatRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.Mensaje == "" { + return c.Status(400).JSON(fiber.Map{"error": "mensaje requerido"}) + } + + ai, err := models.GetAgenteBotAiConfig() + if err != nil { + return c.Status(503).JSON(fiber.Map{"error": fmt.Sprintf("Asistente no configurado: %v", err)}) + } + + respuesta, err := services.ProcessAgentMessage(asistenteChatID(c), req.Mensaje, ai) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "respuesta": respuesta}) +} + +// DeleteAsistenteHistorial borra el historial de la conversación del usuario actual. +// DELETE /app/api/asistente/historial +func DeleteAsistenteHistorial(c *fiber.Ctx) error { + if err := models.ClearAgentHistory(asistenteChatID(c)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/rest/controllers/contabilidad_controller.go b/rest/controllers/contabilidad_controller.go index a53ffc1..5fbb240 100644 --- a/rest/controllers/contabilidad_controller.go +++ b/rest/controllers/contabilidad_controller.go @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // ─── Render de vistas ──────────────────────────────────────────────────────── @@ -408,12 +409,12 @@ func GetCuentasCobro(c *fiber.Ctx) error { func CreateCuentaCobro(c *fiber.Ctx) error { type Req struct { - ClienteID uint `json:"cliente_id"` - Fecha string `json:"fecha"` - Descripcion string `json:"descripcion"` - Valor float64 `json:"valor"` - FechaVencimiento string `json:"fecha_vencimiento"` - Notas string `json:"notas"` + ClienteID uint `json:"cliente_id"` + Fecha string `json:"fecha"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + FechaVencimiento string `json:"fecha_vencimiento"` + Notas string `json:"notas"` } var req Req if err := c.BodyParser(&req); err != nil { @@ -452,13 +453,13 @@ func UpdateCuentaCobro(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } type Req struct { - Estado string `json:"estado"` - FechaPago string `json:"fecha_pago"` - TransaccionID *uint `json:"transaccion_id"` - Notas string `json:"notas"` - FechaVencimiento string `json:"fecha_vencimiento"` - Descripcion string `json:"descripcion"` - Valor float64 `json:"valor"` + Estado string `json:"estado"` + FechaPago string `json:"fecha_pago"` + TransaccionID *uint `json:"transaccion_id"` + Notas string `json:"notas"` + FechaVencimiento string `json:"fecha_vencimiento"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` } var req Req if err := c.BodyParser(&req); err != nil { @@ -564,10 +565,10 @@ func UpdateCuentaPagar(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } type Req struct { - Estado string `json:"estado"` - FechaPago string `json:"fecha_pago"` - TransaccionID *uint `json:"transaccion_id"` - Notas string `json:"notas"` + Estado string `json:"estado"` + FechaPago string `json:"fecha_pago"` + TransaccionID *uint `json:"transaccion_id"` + Notas string `json:"notas"` } var req Req if err := c.BodyParser(&req); err != nil { @@ -614,6 +615,21 @@ func MarcarCuentaPagarPagada(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) } +// GenerarDocumentoCuentaCobro produce el PDF de solicitud de cuenta de cobro a +// partir de la plantilla activa tipo 'cuenta_cobro'. +// POST /api/v2/contabilidad/cuentas-cobro/:id/generar-documento +func GenerarDocumentoCuentaCobro(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + doc, err := services.GenerarDocumentoCuentaCobro(uint(id), generadoPorFromContext(c)) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc}) +} + func DeleteCuentaPagar(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 32) if err != nil { diff --git a/rest/controllers/contrato_controller.go b/rest/controllers/contrato_controller.go index b627738..37410a3 100644 --- a/rest/controllers/contrato_controller.go +++ b/rest/controllers/contrato_controller.go @@ -462,3 +462,19 @@ func MarcarPagoManual(c *fiber.Ctx) error { go services.EnviarCorreoConfirmacionPago(uint(id), "manual") return c.JSON(fiber.Map{"ok": true, "mensaje": "Pago marcado como confirmado"}) } + +// GenerarDocumentoContrato produce el PDF del contrato (cláusulas estándar) a partir +// de la plantilla activa tipo 'contrato'. La lógica real vive en +// services.GenerarDocumentoContrato, compartida con la tool del agente. +// POST /api/v2/contratos/:id/generar-documento +func GenerarDocumentoContrato(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + doc, err := services.GenerarDocumentoContrato(uint(id), generadoPorFromContext(c)) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"ok": true, "documento": doc}) +} diff --git a/rest/controllers/cotizacion_controller.go b/rest/controllers/cotizacion_controller.go new file mode 100644 index 0000000..a9a7de3 --- /dev/null +++ b/rest/controllers/cotizacion_controller.go @@ -0,0 +1,51 @@ +package controllers + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +type crearCotizacionRequest struct { + ClienteID uint `json:"cliente_id"` + Alcance string `json:"alcance"` + TipoProyecto string `json:"tipo_proyecto"` + Items []services.ItemCotizacion `json:"items"` +} + +// CreateCotizacion genera el PDF de una cotización a partir de la plantilla activa +// tipo 'cotizacion' + los items ya calculados por quien llama (Claude decide los +// valores consultando /api/v2/tarifas, el backend solo arma y suma el documento). +// La lógica real vive en services.CrearCotizacion, compartida con la tool del agente. +// POST /api/v2/cotizaciones +func CreateCotizacion(c *fiber.Ctx) error { + var req crearCotizacionRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + doc, total, err := services.CrearCotizacion(req.ClienteID, req.Alcance, req.TipoProyecto, req.Items, generadoPorFromContext(c)) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(201).JSON(fiber.Map{ + "ok": true, + "message": fmt.Sprintf("Cotización #%d generada", doc.ID), + "total": total, + "documento": doc, + }) +} + +// generadoPorFromContext distingue si la petición vino del dashboard (sesión web, +// AuthApi) o de la API admin/agente (AdminApiAuth con ADMIN_API_KEY / bot). +func generadoPorFromContext(c *fiber.Ctx) string { + if c.Get("X-Agent-Channel") == "telegram" { + return "telegram" + } + if c.Get("Authorization") != "" || c.Get("X-API-Key") != "" { + return "claude_api" + } + return "web" +} diff --git a/rest/controllers/documento_generado_controller.go b/rest/controllers/documento_generado_controller.go new file mode 100644 index 0000000..f977b7c --- /dev/null +++ b/rest/controllers/documento_generado_controller.go @@ -0,0 +1,53 @@ +package controllers + +import ( + "fmt" + "math" + "path/filepath" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// GetDocumentosGenerados lista el historial de documentos producidos por la +// automatización con IA (cotizaciones, contratos, actas, cuentas de cobro), +// sin importar el canal que los generó. +func GetDocumentosGenerados(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + clienteID, _ := strconv.ParseUint(c.Query("cliente_id", "0"), 10, 32) + records, total, err := models.GetAllDocumentosGenerados(limit, offset, c.Query("tipo"), uint(clienteID)) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +// DownloadDocumentoGenerado sirve el PDF ya generado. +func DownloadDocumentoGenerado(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + doc, err := models.GetDocumentoGeneradoByID(uint(id)) + if err != nil || doc.Archivo == "" { + return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"}) + } + clean := filepath.Clean(doc.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + c.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, doc.Nombre)) + return c.SendFile(clean) +} diff --git a/rest/controllers/hermes_spec_controller.go b/rest/controllers/hermes_spec_controller.go index 61c2e70..5cbcdf2 100644 --- a/rest/controllers/hermes_spec_controller.go +++ b/rest/controllers/hermes_spec_controller.go @@ -143,6 +143,7 @@ func AdminApiSpec(c *fiber.Ctx) error { {"method": "GET", "path": "/api/v2/contratos/:id/historial", "desc": "Historial del contrato"}, {"method": "POST", "path": "/api/v2/contratos/:id/verificar-pago", "desc": "Verificar pago Bold"}, {"method": "POST", "path": "/api/v2/contratos/:id/marcar-pagado", "desc": "Marcar pago manual"}, + {"method": "POST", "path": "/api/v2/contratos/:id/generar-documento", "desc": "Genera el PDF del contrato (cláusulas estándar) desde la plantilla activa tipo 'contrato'"}, }, }, { @@ -290,6 +291,7 @@ func AdminApiSpec(c *fiber.Ctx) error { {"method": "POST", "path": "/api/v2/contabilidad/cuentas-cobro", "desc": "Crear cuenta por cobrar", "body": "cliente_id, descripcion, valor, fecha, fecha_vencimiento, notas"}, {"method": "PUT", "path": "/api/v2/contabilidad/cuentas-cobro/:id", "desc": "Actualizar cuenta por cobrar"}, {"method": "DELETE", "path": "/api/v2/contabilidad/cuentas-cobro/:id", "desc": "Eliminar cuenta por cobrar"}, + {"method": "POST", "path": "/api/v2/contabilidad/cuentas-cobro/:id/generar-documento", "desc": "Genera el PDF de solicitud de cuenta de cobro desde la plantilla activa tipo 'cuenta_cobro'"}, {"method": "GET", "path": "/api/v2/contabilidad/cuentas-pagar", "desc": "Listar cuentas por pagar. Query: ?page=&search=&estado="}, {"method": "POST", "path": "/api/v2/contabilidad/cuentas-pagar", "desc": "Crear cuenta por pagar", "body": "entidad_id, descripcion, valor, fecha, vencimiento, notas"}, {"method": "PUT", "path": "/api/v2/contabilidad/cuentas-pagar/:id", "desc": "Actualizar cuenta por pagar"}, @@ -494,6 +496,45 @@ func AdminApiSpec(c *fiber.Ctx) error { {"method": "GET", "path": "/api/v2/vcard-api/miniwebs", "desc": "Listar miniwebs"}, }, }, + { + "nombre": "Automatización IA: Plantillas de documento", + "endpoints": []fiber.Map{ + {"method": "GET", "path": "/api/v2/plantillas-documento", "desc": "Listar plantillas. Query: ?tipo=cotizacion|contrato|acta|cuenta_cobro&search="}, + {"method": "GET", "path": "/api/v2/plantillas-documento/:id", "desc": "Detalle de una plantilla"}, + {"method": "POST", "path": "/api/v2/plantillas-documento", "desc": "Crear plantilla", "body": "tipo, nombre, contenido_html, version, activa"}, + {"method": "PUT", "path": "/api/v2/plantillas-documento/:id", "desc": "Actualizar plantilla"}, + {"method": "DELETE", "path": "/api/v2/plantillas-documento/:id", "desc": "Eliminar plantilla"}, + }, + }, + { + "nombre": "Automatización IA: Tarifas", + "endpoints": []fiber.Map{ + {"method": "GET", "path": "/api/v2/tarifas", "desc": "Listar tarifas. Query: ?categoria=hora_servicio|licencia|vm_azure|margen|otro&search="}, + {"method": "POST", "path": "/api/v2/tarifas", "desc": "Crear tarifa", "body": "categoria, nombre, valor, moneda, unidad, notas"}, + {"method": "PUT", "path": "/api/v2/tarifas/:id", "desc": "Actualizar tarifa"}, + {"method": "DELETE", "path": "/api/v2/tarifas/:id", "desc": "Eliminar tarifa"}, + }, + }, + { + "nombre": "Automatización IA: Arquitecturas", + "endpoints": []fiber.Map{ + {"method": "GET", "path": "/api/v2/arquitecturas", "desc": "Listar patrones de arquitectura de referencia. Query: ?search=&solo_referencias=true"}, + {"method": "POST", "path": "/api/v2/arquitecturas", "desc": "Genera el PDF de una propuesta técnica ya redactada", "body": "requerimiento, propuesta, nombre, cliente_id, guardar_como_referencia"}, + }, + }, + { + "nombre": "Automatización IA: Cotizaciones", + "endpoints": []fiber.Map{ + {"method": "POST", "path": "/api/v2/cotizaciones", "desc": "Genera el PDF de una cotización desde la plantilla activa. Consultar /api/v2/tarifas antes para calcular los items", "body": "cliente_id, alcance, tipo_proyecto, items: [{descripcion, cantidad, valor_unitario, unidad}]"}, + }, + }, + { + "nombre": "Automatización IA: Historial de documentos generados", + "endpoints": []fiber.Map{ + {"method": "GET", "path": "/api/v2/documentos-generados", "desc": "Historial de documentos generados. Query: ?tipo=&cliente_id="}, + {"method": "GET", "path": "/api/v2/documentos-generados/:id/download", "desc": "Descargar el PDF generado"}, + }, + }, }, } return c.JSON(spec) diff --git a/rest/controllers/plantilla_documento_controller.go b/rest/controllers/plantilla_documento_controller.go new file mode 100644 index 0000000..3332a4f --- /dev/null +++ b/rest/controllers/plantilla_documento_controller.go @@ -0,0 +1,162 @@ +package controllers + +import ( + "math" + "strconv" + "text/template" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ──── + +func PlantillasDocumentoView(c *fiber.Ctx) error { + return c.Render("automatizacion/plantillas_documento", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func TarifasView(c *fiber.Ctx) error { + return c.Render("automatizacion/tarifas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetPlantillasDocumento(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllPlantillasDocumento(limit, offset, c.Query("search"), c.Query("tipo")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func GetPlantillaDocumento(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + item, err := models.GetPlantillaDocumentoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrada"}) + } + return c.JSON(item) +} + +func CreatePlantillaDocumento(c *fiber.Ctx) error { + var m models.PlantillaDocumento + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if m.ContenidoHTML != "" { + if _, err := template.New("validate").Parse(m.ContenidoHTML); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Sintaxis inválida en el HTML: " + err.Error()}) + } + } + if err := models.CreatePlantillaDocumento(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Plantilla creada", "ok": true}) +} + +func UpdatePlantillaDocumento(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var updates map[string]interface{} + if err := c.BodyParser(&updates); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if html, ok := updates["contenido_html"].(string); ok && html != "" { + if _, err := template.New("validate").Parse(html); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Sintaxis inválida en el HTML: " + err.Error()}) + } + } + if err := models.UpdatePlantillaDocumento(uint(id), updates); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeletePlantillaDocumento(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeletePlantillaDocumento(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} + +// ─── Tarifas ──────────────────────────────────────────────────────────────── + +func GetTarifas(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "50")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllTarifas(limit, offset, c.Query("search"), c.Query("categoria")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func CreateTarifa(c *fiber.Ctx) error { + var m models.Tarifa + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.CreateTarifa(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Tarifa creada", "ok": true}) +} + +func UpdateTarifa(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var updates map[string]interface{} + if err := c.BodyParser(&updates); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.UpdateTarifa(uint(id), updates); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeleteTarifa(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteTarifa(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index 3f93bdc..361cffc 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -104,6 +104,7 @@ func AdminApiRoutes(api fiber.Router) { h.Get("/contratos/:id/historial", controllers.GetHistorialContrato) h.Post("/contratos/:id/verificar-pago", controllers.VerificarPagoBold) h.Post("/contratos/:id/marcar-pagado", controllers.MarcarPagoManual) + h.Post("/contratos/:id/generar-documento", controllers.GenerarDocumentoContrato) // ─── Plantillas de correo ──────────────────────────────────────────────── h.Get("/plantillas-correo", controllers.GetPlantillas) @@ -218,6 +219,7 @@ func AdminApiRoutes(api fiber.Router) { h.Post("/contabilidad/cuentas-cobro", controllers.CreateCuentaCobro) h.Put("/contabilidad/cuentas-cobro/:id", controllers.UpdateCuentaCobro) h.Delete("/contabilidad/cuentas-cobro/:id", controllers.DeleteCuentaCobro) + h.Post("/contabilidad/cuentas-cobro/:id/generar-documento", controllers.GenerarDocumentoCuentaCobro) // Cuentas por pagar h.Get("/contabilidad/cuentas-pagar", controllers.GetCuentasPagar) h.Post("/contabilidad/cuentas-pagar", controllers.CreateCuentaPagar) @@ -401,6 +403,30 @@ func AdminApiRoutes(api fiber.Router) { h.Delete("/agent/auth/:id", controllers.AgentAuthDelete) h.Delete("/agent/history/:chat_id", controllers.AgentHistoryClear) + // ─── Automatización IA: Cotizaciones ───────────────────────────────────── + h.Post("/cotizaciones", controllers.CreateCotizacion) + + // ─── Automatización IA: Arquitecturas ──────────────────────────────────── + h.Get("/arquitecturas", controllers.GetArquitecturas) + h.Post("/arquitecturas", controllers.CreateArquitectura) + + // ─── Automatización IA: Historial de documentos generados ─────────────── + h.Get("/documentos-generados", controllers.GetDocumentosGenerados) + h.Get("/documentos-generados/:id/download", controllers.DownloadDocumentoGenerado) + + // ─── Automatización IA: Plantillas de documento ───────────────────────── + h.Get("/plantillas-documento", controllers.GetPlantillasDocumento) + h.Get("/plantillas-documento/:id", controllers.GetPlantillaDocumento) + h.Post("/plantillas-documento", controllers.CreatePlantillaDocumento) + h.Put("/plantillas-documento/:id", controllers.UpdatePlantillaDocumento) + h.Delete("/plantillas-documento/:id", controllers.DeletePlantillaDocumento) + + // ─── Automatización IA: Tarifas ────────────────────────────────────────── + h.Get("/tarifas", controllers.GetTarifas) + h.Post("/tarifas", controllers.CreateTarifa) + h.Put("/tarifas/:id", controllers.UpdateTarifa) + h.Delete("/tarifas/:id", controllers.DeleteTarifa) + // ─── VCard API ─────────────────────────────────────────────────────────── h.Get("/vcard-api/config", controllers.VcardApiGetConfig) h.Get("/vcard-api/usuarios", controllers.VcardApiUsuarios) diff --git a/rest/routes/renovaciones.go b/rest/routes/renovaciones.go index 68a476b..3ad6e02 100644 --- a/rest/routes/renovaciones.go +++ b/rest/routes/renovaciones.go @@ -71,6 +71,36 @@ func RenovacionesRoutes(protected fiber.Router) { protected.Post("/api/smtp-config", controllers.SaveSmtpConfig) protected.Post("/api/smtp-config/test", controllers.TestSmtpConfig) + // ─── Automatización IA: Asistente (chat propio) ─────────────────── + protected.Get("/asistente", middlewares.MenuMiddleware, controllers.AsistenteView) + protected.Post("/api/asistente/chat", controllers.PostAsistenteChat) + protected.Delete("/api/asistente/historial", controllers.DeleteAsistenteHistorial) + + // ─── Automatización IA: Plantillas de documento ──────────────────── + protected.Get("/automatizacion/plantillas", middlewares.MenuMiddleware, controllers.PlantillasDocumentoView) + protected.Get("/api/plantillas-documento", controllers.GetPlantillasDocumento) + protected.Get("/api/plantillas-documento/:id", controllers.GetPlantillaDocumento) + protected.Post("/api/plantillas-documento", controllers.CreatePlantillaDocumento) + protected.Put("/api/plantillas-documento/:id", controllers.UpdatePlantillaDocumento) + protected.Delete("/api/plantillas-documento/:id", controllers.DeletePlantillaDocumento) + + // ─── Automatización IA: Tarifas ───────────────────────────────────── + protected.Get("/automatizacion/tarifas", middlewares.MenuMiddleware, controllers.TarifasView) + protected.Get("/api/tarifas", controllers.GetTarifas) + protected.Post("/api/tarifas", controllers.CreateTarifa) + protected.Put("/api/tarifas/:id", controllers.UpdateTarifa) + protected.Delete("/api/tarifas/:id", controllers.DeleteTarifa) + + // ─── Automatización IA: Arquitecturas ─────────────────────────────── + protected.Get("/api/arquitecturas", controllers.GetArquitecturas) + protected.Post("/api/arquitecturas", controllers.CreateArquitectura) + + // ─── Automatización IA: Cotizaciones y documentos generados ──────── + protected.Post("/api/cotizaciones", controllers.CreateCotizacion) + protected.Post("/api/contratos/:id/generar-documento", controllers.GenerarDocumentoContrato) + protected.Get("/api/documentos-generados", controllers.GetDocumentosGenerados) + protected.Get("/api/documentos-generados/:id/download", controllers.DownloadDocumentoGenerado) + // ─── Diagnóstico ────────────────────────────────────────────────── protected.Post("/api/diagnostico/ciclo-notificaciones", controllers.EjecutarTestCiclo) @@ -79,4 +109,8 @@ func RenovacionesRoutes(protected fiber.Router) { migrations.SeedRenovaciones() return c.JSON(fiber.Map{"ok": true, "message": "Seed de Renovaciones ejecutado correctamente"}) }) + protected.Post("/api/admin/seed-automatizacion-ia", func(c *fiber.Ctx) error { + migrations.SeedAutomatizacionIA() + return c.JSON(fiber.Map{"ok": true, "message": "Seed de Automatización IA ejecutado correctamente"}) + }) } diff --git a/rest/routes/user.go b/rest/routes/user.go index 03eadf4..8fe14ec 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -47,13 +47,13 @@ func UserRoutes(app fiber.Router) { protected.Delete("/submodules/:id", controllers.DeleteSubmodule) // Eliminar un módulo // Rutas de usuarios - protected.Get("/users", middlewares.MenuMiddleware, controllers.Users) // Renderizar la vista - protected.Get("/loadusers", controllers.GetUsers) // Obtener - protected.Post("/users", controllers.CreateUser) // Crear - protected.Put("/users/:id", controllers.UpdateUser) // Actualizar - protected.Put("/password/:id", controllers.UpdatePassword) // Actualizar - protected.Delete("/users/:id", controllers.DeleteUser) // Eliminar - protected.Get("/user/:id", controllers.GetUser) // Buscar un usuario + protected.Get("/users", middlewares.MenuMiddleware, controllers.Users) // Renderizar la vista + protected.Get("/loadusers", controllers.GetUsers) // Obtener + protected.Post("/users", controllers.CreateUser) // Crear + protected.Put("/users/:id", controllers.UpdateUser) // Actualizar + protected.Put("/password/:id", controllers.UpdatePassword) // Actualizar + protected.Delete("/users/:id", controllers.DeleteUser) // Eliminar + protected.Get("/user/:id", controllers.GetUser) // Buscar un usuario protected.Post("/users/:id/credenciales", controllers.EnviarCredencialesUsuario) // Enviar credenciales // Rutas de conexiones ssh @@ -486,9 +486,9 @@ func UserRoutes(app fiber.Router) { // ─── Contabilidad ───────────────────────────────────────────────────────── protected.Get("/contabilidad", middlewares.MenuMiddleware, controllers.ContabilidadIndex) -protected.Get("/contabilidad/dashboard", controllers.ContabilidadDashboard) -protected.Get("/contabilidad/consolidado", controllers.ContabilidadConsolidado) -protected.Get("/contabilidad/consolidados", controllers.ContabilidadListConsolidados) + protected.Get("/contabilidad/dashboard", controllers.ContabilidadDashboard) + protected.Get("/contabilidad/consolidado", controllers.ContabilidadConsolidado) + protected.Get("/contabilidad/consolidados", controllers.ContabilidadListConsolidados) // Transacciones protected.Get("/contabilidad/transacciones", middlewares.MenuMiddleware, controllers.ContabilidadTransaccionesView) @@ -519,6 +519,7 @@ protected.Get("/contabilidad/consolidados", controllers.ContabilidadListConsolid protected.Post("/contabilidad/cuentas-cobro", controllers.CreateCuentaCobro) protected.Put("/contabilidad/cuentas-cobro/:id", controllers.UpdateCuentaCobro) protected.Delete("/contabilidad/cuentas-cobro/:id", controllers.DeleteCuentaCobro) + protected.Post("/contabilidad/cuentas-cobro/:id/generar-documento", controllers.GenerarDocumentoCuentaCobro) // Cuentas por pagar protected.Get("/contabilidad/cuentas-pagar", middlewares.MenuMiddleware, controllers.ContabilidadPagarView)