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:
co-authored by
Claude Opus 5
parent
c8e5afceb2
commit
5ac70a4d6e
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user