fix: tareas del agente invisibles en el Kanban + facturas de compra por Telegram

- El agente creaba tareas con estado='pendiente' (default), pero el tablero
  Kanban del dashboard solo reconoce por_hacer/en_progreso/revision/hecho. La
  tarea se guardaba bien (por eso llegaba el correo de notificación) pero no
  aparecía en ninguna columna. Se corrigen los enums y el default de las tools
  crear_tarea/actualizar_estado_tarea, y se agrega una reparación única al
  arranque que corrige las tareas ya creadas con el estado inválido.

- adjuntar_factura siempre asumía factura de VENTA (ligada a un cliente). Se
  agrega adjuntar_factura_compra: cuando un proveedor le factura a U-SITE (no
  al revés), busca/crea la Entidad proveedor y registra una cuenta por pagar
  con el documento adjunto (se agregan campos archivo/original_name/tipo_mime
  a CuentaPagar, que no los tenía). El prompt del sistema instruye a Claude a
  decidir la dirección leyendo quién emite y quién recibe el documento.
  De paso: endpoint de descarga del soporte de la factura de compra, expuesto
  también en el dashboard (/app/contabilidad/cuentas-pagar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 14:53:47 +00:00
co-authored by Claude Opus 5
parent c8e5afceb2
commit 5ac70a4d6e
10 changed files with 181 additions and 7 deletions
+5
View File
@@ -170,6 +170,11 @@ func main() {
migrations.SeedUrlMonitor()
migrations.SeedTareas()
migrations.SeedAutomatizacionIA()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
} else if n > 0 {
log.Printf("[FIX] %d tarea(s) con estado inválido reparadas (ahora visibles en el Kanban)", n)
}
// 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
+34
View File
@@ -3,6 +3,7 @@ package models
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -93,6 +94,11 @@ type CuentaPagar struct {
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"`
// Soporte de la factura de compra (el documento del proveedor), igual que
// Factura.Archivo para las facturas de venta.
Archivo string `json:"archivo" gorm:"column:archivo"`
OriginalName string `json:"original_name" gorm:"column:original_name"`
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
}
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
@@ -190,6 +196,26 @@ func CreateEntidad(e *Entidad) error {
return app.Http.Database.DB.Create(e).Error
}
// GetOrCreateEntidadProveedor busca un proveedor por nombre (sin distinguir
// mayúsculas) y lo crea si no existe. Se usa para registrar facturas de compra
// sin obligar a dar de alta al proveedor manualmente primero.
func GetOrCreateEntidadProveedor(nombre string) (*Entidad, error) {
nombre = strings.TrimSpace(nombre)
if nombre == "" {
return nil, fmt.Errorf("nombre de proveedor requerido")
}
var e Entidad
err := app.Http.Database.DB.Where("LOWER(nombre) = LOWER(?)", nombre).First(&e).Error
if err == nil {
return &e, nil
}
e = Entidad{Nombre: nombre, Tipo: "proveedor", Activo: true}
if err := app.Http.Database.DB.Create(&e).Error; err != nil {
return nil, fmt.Errorf("no se pudo crear el proveedor: %w", err)
}
return &e, nil
}
func UpdateEntidad(e *Entidad) error {
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
"nombre": e.Nombre,
@@ -582,6 +608,14 @@ func DeleteCuentaCobro(id uint) error {
// ─── CRUD: CuentaPagar ──────────────────────────────────────────────────────
// =============================================================================
func GetCuentaPagarByID(id uint) (*CuentaPagar, error) {
var item CuentaPagar
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) {
var items []CuentaPagar
var total int64
+24
View File
@@ -23,6 +23,30 @@ type Tarea struct {
func (Tarea) TableName() string { return "tarea" }
// RepararEstadosTareaInvalidos corrige tareas que quedaron con un estado que el
// tablero Kanban no reconoce (columnas válidas: por_hacer, en_progreso, revision,
// hecho). El agente de Telegram creó tareas con "pendiente"/"completada"/
// "cancelada" antes de esta corrección: se guardaban bien en la base de datos
// pero no aparecían en ninguna columna del listado. Es idempotente — una vez
// corregidas, no vuelve a tocarlas.
func RepararEstadosTareaInvalidos() (int64, error) {
db := app.Http.Database.DB
var total int64
mapa := map[string]string{
"pendiente": "por_hacer",
"completada": "hecho",
"cancelada": "hecho",
}
for viejo, nuevo := range mapa {
result := db.Model(&Tarea{}).Where("estado = ?", viejo).Update("estado", nuevo)
if result.Error != nil {
return total, result.Error
}
total += result.RowsAffected
}
return total, nil
}
type TareaComentario struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
+62
View File
@@ -0,0 +1,62 @@
package services
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// AdjuntarFacturaCompra toma el documento pendiente de Telegram para este chat
// y lo registra como una cuenta por pagar (factura de compra: un proveedor le
// factura a U-SITE), a diferencia de AdjuntarFacturaVenta que crea una Factura
// ligada a un cliente (U-SITE factura al cliente).
//
// El proveedor se busca por nombre y se crea si no existe, para no obligar a
// darlo de alta manualmente antes de poder registrar la primera compra.
func AdjuntarFacturaCompra(chatID int64, proveedorNombre, descripcion string, monto float64) (*models.CuentaPagar, error) {
if proveedorNombre == "" {
return nil, fmt.Errorf("nombre del proveedor requerido")
}
if monto <= 0 {
return nil, fmt.Errorf("monto debe ser mayor a cero")
}
att, ok := PopTelegramAttachment(chatID)
if !ok {
return nil, fmt.Errorf("no hay ningún documento pendiente en este chat; pide al usuario que lo envíe de nuevo")
}
entidad, err := models.GetOrCreateEntidadProveedor(proveedorNombre)
if err != nil {
_ = os.Remove(att.Path)
return nil, err
}
dir := "uploads/cuentas_pagar"
if err := os.MkdirAll(dir, 0755); err != nil {
_ = os.Remove(att.Path)
return nil, fmt.Errorf("no se pudo crear el directorio: %w", err)
}
ext := filepath.Ext(att.OriginalName)
finalPath := filepath.Join(dir, fmt.Sprintf("%d_%s%s", entidad.ID, time.Now().Format("20060102150405"), ext))
if err := os.Rename(att.Path, finalPath); err != nil {
return nil, fmt.Errorf("no se pudo guardar el archivo: %w", err)
}
cp := &models.CuentaPagar{
EntidadID: entidad.ID,
Fecha: time.Now(),
Descripcion: descripcion,
Valor: monto,
Estado: "pendiente",
Archivo: finalPath,
OriginalName: att.OriginalName,
TipoMime: att.MimeType,
}
if err := models.CreateCuentaPagar(cp); err != nil {
return nil, fmt.Errorf("no se pudo registrar la cuenta por pagar: %w", err)
}
return cp, nil
}
+4 -1
View File
@@ -15,7 +15,10 @@ func CrearTareaAsignada(titulo, descripcion, estado, prioridad string, asignadoI
return nil, fmt.Errorf("titulo requerido")
}
if estado == "" {
estado = "pendiente"
// Debe coincidir con las columnas del Kanban (tareas.html): por_hacer,
// en_progreso, revision, hecho. "pendiente" no es ninguna de esas — una
// tarea con ese estado se guarda pero no aparece en ninguna columna.
estado = "por_hacer"
}
if prioridad == "" {
prioridad = "media"
+23 -6
View File
@@ -167,13 +167,19 @@ func agentTools() []agentTool {
"search": str("Búsqueda por número, cliente o descripción"),
"estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}},
}, nil)),
tool("adjuntar_factura", "Guarda como factura el documento (PDF/foto) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente; si no lo hay, pide al usuario que lo reenvíe.",
tool("adjuntar_factura", "Guarda como factura de VENTA el documento (PDF/foto) que el usuario acaba de enviar por Telegram: U-SITE es quien factura/cobra al cliente. Solo funciona si hay un archivo adjunto pendiente. Si el documento es al revés (un proveedor le factura a U-SITE), usa adjuntar_factura_compra en su lugar, no esta.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente al que pertenece la factura (usa listar_clientes si no lo sabes)"),
"numero": str("Número de factura, si el usuario lo indicó"),
"monto": num("Monto de la factura, si el usuario lo indicó"),
"descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"),
}, []string{"cliente_id"})),
tool("adjuntar_factura_compra", "Guarda como factura de COMPRA (cuenta por pagar) el documento que el usuario acaba de enviar por Telegram: un proveedor le está facturando a U-SITE, no al revés. Busca o crea el proveedor por nombre. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proveedor": str("Nombre del proveedor que emitió la factura (quien la firma/factura, no U-SITE)"),
"monto": num("Monto de la factura"),
"descripcion": str("Descripción breve de la compra, ej: 'Hosting AWS octubre'"),
}, []string{"proveedor", "monto"})),
// ── Clientes ─────────────────────────────────────────────────────────
tool("listar_clientes", "Lista clientes con paginación y búsqueda.",
@@ -346,7 +352,7 @@ func agentTools() []agentTool {
obj(map[string]agentToolParam{
"titulo": str("Título de la tarea"),
"descripcion": str("Descripción"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso"}, Description: "Estado inicial"},
"estado": agentToolParam{Type: "string", Enum: []string{"por_hacer", "en_progreso"}, Description: "Estado inicial (default por_hacer). Debe coincidir con las columnas del tablero Kanban."},
"prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"},
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
"fecha_limite": str("Fecha límite, formato YYYY-MM-DD"),
@@ -356,10 +362,14 @@ func agentTools() []agentTool {
"id": num("ID de la tarea"),
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
}, []string{"id", "asignado_id"})),
tool("actualizar_estado_tarea", "Cambia el estado de una tarea.",
tool("actualizar_estado_tarea", "Cambia el estado de una tarea. Los valores deben coincidir con las columnas del tablero Kanban del dashboard.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completada", "cancelada"}, Description: "Nuevo estado"},
"id": num("ID de la tarea"),
"estado": agentToolParam{
Type: "string",
Enum: []string{"por_hacer", "en_progreso", "revision", "hecho"},
Description: "por_hacer=nueva, en_progreso=en curso, revision=terminada pendiente de revisar, hecho=cerrada",
},
}, []string{"id", "estado"})),
// ── Servidores ───────────────────────────────────────────────────────
@@ -617,6 +627,13 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
return map[string]interface{}{"ok": true, "factura_id": f.ID}, nil
case "adjuntar_factura_compra":
cp, err := AdjuntarFacturaCompra(chatID, getStr("proveedor"), getStr("descripcion"), float64(getInt("monto", 0)))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "cuenta_pagar_id": cp.ID, "proveedor_id": cp.EntidadID}, nil
case "listar_clientes":
page := getInt("page", 1)
search := getStr("search")
@@ -1478,7 +1495,7 @@ COMPORTAMIENTO:
- Si una herramienta falla, explica el error y sugiere alternativas
- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo:
· Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura
· Si es una factura → primero determina la dirección leyendo quién emite y quién recibe: si el emisor/vendedor es "U-Site S.A.S BIC" (o similar) y el destinatario es un cliente, es una factura de VENTA → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura. Si es al revés — un proveedor (AWS, Coolify, un hosting, cualquier tercero) le está facturando A U-Site — es una factura de COMPRA → llama a adjuntar_factura_compra con el nombre de ese proveedor, no busques cliente_id. Ante la duda, mira a nombre de quién sale la plata: si U-Site cobra, es venta; si U-Site paga, es compra
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
Si logras identificar los datos necesarios (del documento o de lo que escribió el usuario), guarda directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente/proyecto o falta un dato imprescindible. No uses estas tools si el mensaje no menciona ningún documento adjunto
- Para registrar un proyecto nuevo con sus fases (el usuario puede describirlo en texto o mandarlo en un documento), primero usa crear_proyecto y luego llama a crear_fase_proyecto una vez por cada fase que corresponda — no hace falta preguntar confirmación entre cada fase
@@ -47,6 +47,7 @@
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<a :href="`/app/contabilidad/cuentas-pagar/${c.ID}/archivo`" target="_blank" x-show="c.archivo" class="btn-icon text-slate-500" title="Ver soporte de la factura">📎</a>
<button @click="marcarPagado(c)" class="btn-icon text-green-500" title="Marcar pagado" x-show="c.estado!=='pagado'"></button>
<button @click="confirmDelete(c)" class="btn-icon text-red-500">🗑️</button>
</div>
@@ -1,8 +1,11 @@
package controllers
import (
"fmt"
"math"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
@@ -665,3 +668,26 @@ func DeleteCuentaPagar(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"ok": true})
}
// DownloadCuentaPagarArchivo sirve el soporte de una factura de compra
// (subida desde el dashboard o adjuntada por Telegram).
func DownloadCuentaPagarArchivo(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"})
}
cp, err := models.GetCuentaPagarByID(uint(id))
if err != nil || cp.Archivo == "" {
return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"})
}
clean := filepath.Clean(cp.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
nombre := cp.OriginalName
if nombre == "" {
nombre = filepath.Base(clean)
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre))
return c.SendFile(clean)
}
+1
View File
@@ -227,6 +227,7 @@ func AdminApiRoutes(api fiber.Router) {
h.Put("/contabilidad/cuentas-pagar/:id", controllers.UpdateCuentaPagar)
h.Post("/contabilidad/cuentas-pagar/:id/pagar", controllers.MarcarCuentaPagarPagada)
h.Delete("/contabilidad/cuentas-pagar/:id", controllers.DeleteCuentaPagar)
h.Get("/contabilidad/cuentas-pagar/:id/archivo", controllers.DownloadCuentaPagarArchivo)
// ─── Productos SaaS ──────────────────────────────────────────────────────
h.Get("/saas", controllers.GetSaasProductos)
+1
View File
@@ -540,6 +540,7 @@ func UserRoutes(app fiber.Router) {
protected.Put("/contabilidad/cuentas-pagar/:id", controllers.UpdateCuentaPagar)
protected.Post("/contabilidad/cuentas-pagar/:id/pagar", controllers.MarcarCuentaPagarPagada)
protected.Delete("/contabilidad/cuentas-pagar/:id", controllers.DeleteCuentaPagar)
protected.Get("/contabilidad/cuentas-pagar/:id/archivo", controllers.DownloadCuentaPagarArchivo)
// ─── WebSMS (LabsMobile) ───────────────────────────────────────────────────
protected.Get("/websms", middlewares.MenuMiddleware, controllers.WebSmsConfigPage)