diff --git a/main.go b/main.go index f914d3e..98662ee 100755 --- a/main.go +++ b/main.go @@ -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 diff --git a/pkg/models/contabilidad.go b/pkg/models/contabilidad.go index 87c14f0..717d955 100644 --- a/pkg/models/contabilidad.go +++ b/pkg/models/contabilidad.go @@ -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 diff --git a/pkg/models/tarea.go b/pkg/models/tarea.go index 6374134..09af5f2 100644 --- a/pkg/models/tarea.go +++ b/pkg/models/tarea.go @@ -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"` diff --git a/pkg/services/factura_compra_service.go b/pkg/services/factura_compra_service.go new file mode 100644 index 0000000..749445c --- /dev/null +++ b/pkg/services/factura_compra_service.go @@ -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 +} diff --git a/pkg/services/tarea_service.go b/pkg/services/tarea_service.go index 774f919..24f101a 100644 --- a/pkg/services/tarea_service.go +++ b/pkg/services/tarea_service.go @@ -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" diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 6c68843..4057115 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -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 diff --git a/resources/views/contabilidad/cuentas_pagar.html b/resources/views/contabilidad/cuentas_pagar.html index 4773250..cc119dc 100644 --- a/resources/views/contabilidad/cuentas_pagar.html +++ b/resources/views/contabilidad/cuentas_pagar.html @@ -47,6 +47,7 @@
+ 📎
diff --git a/rest/controllers/contabilidad_controller.go b/rest/controllers/contabilidad_controller.go index ef33518..d911f5b 100644 --- a/rest/controllers/contabilidad_controller.go +++ b/rest/controllers/contabilidad_controller.go @@ -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) +} diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index b3bc9ce..6641807 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -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) diff --git a/rest/routes/user.go b/rest/routes/user.go index bf4d209..1ec7426 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -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)