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
+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