feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos
Dos de las cuatro mejoras de la auditoría del agente. 1. Las herramientas aceptan cliente_nombre además de cliente_id. De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el modelo tenía que encadenar: listar_clientes → leer la respuesta → encontrar la fila → extraer el número → recién ahí llamar a la herramienta. Cuatro pasos, y basta que falle uno para que no se guarde nada — es la explicación más probable de la factura que nunca se creó, porque ese cliente podía no venir en la primera página del listado. Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda completa y no paginada. Si hay varios candidatos devuelve la lista para que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura a la empresa equivocada es peor que preguntar. Una coincidencia exacta gana sobre las parciales, así "Metropolitana" no queda ambiguo solo porque existe "Metropolitana Norte". 2. Contadores de uso y fallo por herramienta, expuestos como estado_herramientas. Cada problema se venía diagnosticando de a un caso, reconstruyendo qué pasó después de que el usuario avisara. Ahora se puede preguntar directamente qué viene fallando y ver si es una herramienta puntual o el modelo eligiendo mal. En memoria a propósito: alcanza para responder eso y no agrega una tabla. Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales por dominio a las descripciones de las herramientas, y comparar modelos con un mismo caso. La primera conviene hacerla con el sistema estable, porque toca cómo decide el modelo en todos los flujos a la vez. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d0c9ed5656
commit
e2440b04f1
@@ -0,0 +1,121 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// Resolución de entidades por nombre para el agente de Telegram.
|
||||
//
|
||||
// La mayoría de las herramientas pedían un ID numérico, y el modelo tenía que
|
||||
// conseguirlo antes: llamar a listar_*, leer la respuesta, encontrar la fila
|
||||
// correcta y extraer el número. Cuatro pasos encadenados por cada acción, y
|
||||
// basta que falle uno para que no se guarde nada — es lo que pasó con una
|
||||
// factura cuyo cliente no salía en la primera página del listado.
|
||||
//
|
||||
// Aceptando el nombre y resolviéndolo acá, esa cadena desaparece: el modelo
|
||||
// pasa lo que el usuario dijo y el servidor hace la búsqueda, que además es
|
||||
// exacta y no depende de que el listado estuviera paginado.
|
||||
|
||||
// ResolverClienteID devuelve el ID a partir de un id explícito o de un nombre.
|
||||
//
|
||||
// Si el nombre coincide con varios clientes devuelve un error que los enumera:
|
||||
// es mejor que el modelo pregunte cuál a que elija uno al azar y la factura
|
||||
// termine cargada a otra empresa.
|
||||
func ResolverClienteID(id uint, nombre string) (uint, error) {
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
nombre = strings.TrimSpace(nombre)
|
||||
if nombre == "" {
|
||||
return 0, fmt.Errorf("indicá el cliente: su nombre o su id")
|
||||
}
|
||||
|
||||
items, _, err := models.GetAllClientes(50, 0, nombre)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return 0, fmt.Errorf("no encontré ningún cliente que coincida con %q; revisá el nombre o creálo con crear_cliente", nombre)
|
||||
case 1:
|
||||
return items[0].ID, nil
|
||||
}
|
||||
|
||||
// Una coincidencia exacta gana sobre las parciales: "Metropolitana" no
|
||||
// debería quedar ambiguo solo porque existe "Metropolitana Norte".
|
||||
buscado := strings.ToLower(nombre)
|
||||
for _, c := range items {
|
||||
if strings.ToLower(strings.TrimSpace(c.Nombre)) == buscado ||
|
||||
strings.ToLower(strings.TrimSpace(c.Empresa)) == buscado {
|
||||
return c.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var opciones []string
|
||||
for i, c := range items {
|
||||
if i == 8 {
|
||||
opciones = append(opciones, fmt.Sprintf("y %d más", len(items)-8))
|
||||
break
|
||||
}
|
||||
etiqueta := c.Nombre
|
||||
if c.Empresa != "" && !strings.EqualFold(c.Empresa, c.Nombre) {
|
||||
etiqueta = fmt.Sprintf("%s (%s)", c.Nombre, c.Empresa)
|
||||
}
|
||||
opciones = append(opciones, fmt.Sprintf("%d = %s", c.ID, etiqueta))
|
||||
}
|
||||
return 0, fmt.Errorf("hay varios clientes que coinciden con %q, preguntale al usuario cuál: %s",
|
||||
nombre, strings.Join(opciones, "; "))
|
||||
}
|
||||
|
||||
// ─── Salud de las herramientas ───────────────────────────────────────────────
|
||||
|
||||
// Contadores en memoria de uso y fallo por herramienta.
|
||||
//
|
||||
// Hasta ahora cada problema se diagnosticaba de a un caso: el usuario avisaba
|
||||
// que algo no se guardó y había que reconstruir qué pasó. Con esto se ve de
|
||||
// una si el fallo es de una herramienta puntual o del modelo eligiendo mal.
|
||||
//
|
||||
// ponytail: en memoria, se reinicia con el proceso. Alcanza para responder
|
||||
// "¿qué está fallando esta semana?"; si hiciera falta histórico, va a tabla.
|
||||
var (
|
||||
agentStatsMu sync.Mutex
|
||||
agentUsos = map[string]int{}
|
||||
agentFallos = map[string]int{}
|
||||
)
|
||||
|
||||
func RegistrarUsoHerramienta(nombre string, fallo bool) {
|
||||
agentStatsMu.Lock()
|
||||
defer agentStatsMu.Unlock()
|
||||
agentUsos[nombre]++
|
||||
if fallo {
|
||||
agentFallos[nombre]++
|
||||
}
|
||||
}
|
||||
|
||||
// EstadoHerramientas devuelve el resumen ordenado por cantidad de fallos.
|
||||
func EstadoHerramientas() []map[string]interface{} {
|
||||
agentStatsMu.Lock()
|
||||
defer agentStatsMu.Unlock()
|
||||
|
||||
out := make([]map[string]interface{}, 0, len(agentUsos))
|
||||
for nombre, usos := range agentUsos {
|
||||
out = append(out, map[string]interface{}{
|
||||
"herramienta": nombre,
|
||||
"usos": usos,
|
||||
"fallos": agentFallos[nombre],
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
fi, fj := out[i]["fallos"].(int), out[j]["fallos"].(int)
|
||||
if fi != fj {
|
||||
return fi > fj
|
||||
}
|
||||
return out[i]["usos"].(int) > out[j]["usos"].(int)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -134,6 +134,8 @@ func agentTools() []agentTool {
|
||||
|
||||
return []agentTool{
|
||||
// ── Sistema ──────────────────────────────────────────────────────────
|
||||
tool("estado_herramientas", "Muestra qué herramientas se usaron y cuáles fallaron desde que arrancó el sistema. Útil cuando el usuario pregunta por qué algo no se guardó o si algo viene fallando.",
|
||||
obj(map[string]agentToolParam{}, nil)),
|
||||
tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)),
|
||||
|
||||
// ── Coolify ──────────────────────────────────────────────────────────
|
||||
@@ -209,7 +211,8 @@ func agentTools() []agentTool {
|
||||
}, nil)),
|
||||
tool("crear_factura", "Registra una factura de VENTA a partir de datos escritos, SIN archivo adjunto: U-SITE le factura/cobra al cliente. Usa esta cuando el usuario dicta los datos de la factura. Si en cambio acaba de enviar un PDF o una foto de la factura, usa adjuntar_factura.",
|
||||
obj(map[string]agentToolParam{
|
||||
"cliente_id": num("ID del cliente al que se le factura (usa listar_clientes si no lo sabes)"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"monto": num("Monto total de la factura"),
|
||||
"numero": str("Número de factura, ej: FEV 92"),
|
||||
"descripcion": str("Concepto, ej: 'Hosting por 1 año'"),
|
||||
@@ -236,10 +239,11 @@ func agentTools() []agentTool {
|
||||
}, []string{"cuenta_id"})),
|
||||
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'"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"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{
|
||||
@@ -277,7 +281,8 @@ func agentTools() []agentTool {
|
||||
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)"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"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"),
|
||||
@@ -315,10 +320,11 @@ func agentTools() []agentTool {
|
||||
}, 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"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"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"})),
|
||||
@@ -335,9 +341,10 @@ func agentTools() []agentTool {
|
||||
}, 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"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"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",
|
||||
@@ -362,7 +369,8 @@ func agentTools() []agentTool {
|
||||
"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)"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"guardar_como_referencia": agentToolParam{Type: "boolean", Description: "Si true, guarda esta propuesta como patrón reutilizable para el futuro"},
|
||||
}, []string{"requerimiento", "propuesta"})),
|
||||
|
||||
@@ -374,10 +382,11 @@ func agentTools() []agentTool {
|
||||
}, 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"),
|
||||
"cliente_id": num("ID del cliente, si ya lo conocés"),
|
||||
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
|
||||
"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"})),
|
||||
tool("crear_fase_proyecto", "Agrega una fase/etapa a un proyecto existente (ej: Diseño, Desarrollo, QA, Entrega). Se puede llamar varias veces seguidas para registrar todas las fases de un proyecto de una vez.",
|
||||
obj(map[string]agentToolParam{
|
||||
@@ -512,6 +521,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// Resuelve el cliente por id o por nombre. Ver agent_resolver.go: evita que
|
||||
// el modelo tenga que encadenar listar_clientes → leer → extraer id.
|
||||
resolverCliente := func() (uint, error) {
|
||||
return ResolverClienteID(uint(getInt("cliente_id", 0)), getStr("cliente_nombre"))
|
||||
}
|
||||
getBool := func(key string) bool {
|
||||
if v, ok := a[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -691,9 +705,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||
|
||||
case "crear_factura":
|
||||
clienteID := uint(getInt("cliente_id", 0))
|
||||
if clienteID == 0 {
|
||||
return nil, fmt.Errorf("cliente_id requerido")
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
monto := getFloat("monto", 0)
|
||||
if monto <= 0 {
|
||||
@@ -800,9 +814,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no hay ningún documento pendiente en este chat; pide al usuario que lo envíe de nuevo")
|
||||
}
|
||||
clienteID := uint(getInt("cliente_id", 0))
|
||||
if clienteID == 0 {
|
||||
return nil, fmt.Errorf("cliente_id requerido")
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &models.Factura{
|
||||
ClienteID: clienteID,
|
||||
@@ -838,6 +852,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "cuenta_pagar_id": cp.ID, "proveedor_id": cp.EntidadID}, nil
|
||||
|
||||
case "estado_herramientas":
|
||||
return map[string]interface{}{"items": EstadoHerramientas()}, nil
|
||||
|
||||
case "listar_clientes":
|
||||
page := getInt("page", 1)
|
||||
search := getStr("search")
|
||||
@@ -922,8 +939,12 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
servicioIDs = append(servicioIDs, uint(x))
|
||||
}
|
||||
}
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contrato, doc, err := CrearContratoConDocumento(
|
||||
uint(getInt("cliente_id", 0)),
|
||||
clienteID,
|
||||
servicioIDs,
|
||||
getInt("duracion_meses", 12),
|
||||
float64(getInt("precio_acordado", 0)),
|
||||
@@ -1004,8 +1025,12 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||
|
||||
case "crear_cuenta_cobro":
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc, doc, err := CrearCuentaCobroConDocumento(
|
||||
uint(getInt("cliente_id", 0)),
|
||||
clienteID,
|
||||
getStr("descripcion"),
|
||||
getFloat("valor", 0),
|
||||
nil,
|
||||
@@ -1084,7 +1109,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
Unidad: toStr(m["unidad"]),
|
||||
})
|
||||
}
|
||||
doc, total, err := CrearCotizacion(uint(getInt("cliente_id", 0)), getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, total, err := CrearCotizacion(clienteID, getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1132,7 +1161,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
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"))
|
||||
clienteID, err := resolverCliente()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := CrearProyectoSimple(clienteID, getStr("nombre"), getStr("stack"), getStr("descripcion"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1895,7 +1928,9 @@ Comandos: /reset · /instancias · /ayuda`, nil
|
||||
// guardó algo aunque la herramienta haya devuelto un error, así que
|
||||
// el log es la única fuente confiable de qué pasó de verdad.
|
||||
log.Printf("[AGENT] Resultado de %s: %s", tc.Function.Name, recortar(toolResult, 500))
|
||||
if msg := errorDeTool(toolResult); msg != "" {
|
||||
msg := errorDeTool(toolResult)
|
||||
RegistrarUsoHerramienta(tc.Function.Name, msg != "")
|
||||
if msg != "" {
|
||||
fallidas = append(fallidas, fmt.Sprintf("%s: %s", tc.Function.Name, msg))
|
||||
}
|
||||
|
||||
|
||||
@@ -65,3 +65,35 @@ func TestErrorDeTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// El id explícito manda: si el modelo ya lo tiene, no hay que ir a la base.
|
||||
// Es el único camino del resolvedor que se puede probar sin datos.
|
||||
func TestResolverClienteIDPrefiereElIDExplicito(t *testing.T) {
|
||||
id, err := ResolverClienteID(42, "cualquier nombre")
|
||||
if err != nil || id != 42 {
|
||||
t.Fatalf("= (%d, %v), esperaba (42, nil)", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sin id ni nombre no se puede adivinar: mejor un error claro que buscar en la
|
||||
// base con la cadena vacía y traer un cliente al azar.
|
||||
func TestResolverClienteIDSinDatos(t *testing.T) {
|
||||
if _, err := ResolverClienteID(0, " "); err == nil {
|
||||
t.Fatal("esperaba error cuando no hay ni id ni nombre")
|
||||
}
|
||||
}
|
||||
|
||||
// Las herramientas que piden cliente deben aceptar también el nombre: es lo
|
||||
// que elimina el ciclo listar_clientes → leer → extraer id, que es donde se
|
||||
// perdía la factura.
|
||||
func TestHerramientasDeClienteAceptanNombre(t *testing.T) {
|
||||
for _, tl := range agentTools() {
|
||||
props := tl.Function.Parameters.Properties
|
||||
if _, pide := props["cliente_id"]; !pide {
|
||||
continue
|
||||
}
|
||||
if _, acepta := props["cliente_nombre"]; !acepta {
|
||||
t.Errorf("%s pide cliente_id pero no acepta cliente_nombre: obliga al modelo a resolver el id por su cuenta", tl.Function.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user