feat(telegram): registrar una factura de venta sin archivo adjunto

Por qué no aparecía la factura: la única herramienta de venta del bot era
adjuntar_factura, y exige un documento pendiente en el chat. Al dictarle
los datos por texto el modelo no tenía con qué guardar — y como el prompt
tampoco se lo prohibía, informó "guardada exitosamente" sobre algo que
nunca se creó.

/api/v2/facturas ya permitía crearlas, pero el bot no llama a la API REST:
usa su propio registro de herramientas, así que solo puede hacer lo que ese
registro expone. El hueco estaba ahí, no en la API.

crear_factura toma cliente_id y monto (obligatorios), número, concepto y
fechas. Devuelve el monto realmente guardado y el id, que es lo que el
prompt le exige informar en vez de repetir lo que dijo el usuario.

Las descripciones de las dos herramientas se remiten entre sí para que el
modelo no elija la equivocada: con documento adjunto va adjuntar_factura,
con datos dictados va crear_factura. El test verifica esa distinción,
porque elegir mal reproduce exactamente el fallo original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 21:04:07 -05:00
co-authored by Claude Sonnet 5
parent e9cb8107eb
commit d5fbdf6d97
2 changed files with 96 additions and 0 deletions
+51
View File
@@ -207,6 +207,15 @@ 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("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)"),
"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'"),
"fecha_emision": str("Fecha de emisión YYYY-MM-DD; si no se indica, hoy"),
"fecha_vencimiento": str("Fecha de vencimiento YYYY-MM-DD, opcional"),
}, []string{"cliente_id", "monto"})),
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)"),
@@ -663,6 +672,48 @@ 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")
}
monto := getFloat("monto", 0)
if monto <= 0 {
return nil, fmt.Errorf("el monto debe ser mayor a cero")
}
emision := time.Now()
if v := getStr("fecha_emision"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
emision = t
}
}
var vence *time.Time
if v := getStr("fecha_vencimiento"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
vence = &t
}
}
f := &models.Factura{
ClienteID: clienteID,
Numero: getStr("numero"),
Descripcion: getStr("descripcion"),
Monto: monto,
Moneda: "COP",
Estado: "pendiente",
FechaEmision: emision,
FechaVencimiento: vence,
Visible: true,
}
if err := models.CreateFactura(f); err != nil {
return nil, err
}
// Se devuelven los valores guardados, no los pedidos: es lo que el
// prompt le exige informar al usuario.
return map[string]interface{}{
"ok": true, "factura_id": f.ID, "monto_guardado": f.Monto,
"numero": f.Numero, "cliente_id": f.ClienteID,
}, nil
case "adjuntar_factura":
att, ok := PopTelegramAttachment(chatID)
if !ok {
+45
View File
@@ -0,0 +1,45 @@
package services
import (
"strings"
"testing"
)
// Registrar una factura dictando los datos no era posible: la única
// herramienta de venta era adjuntar_factura, que exige un archivo pendiente.
// Sin una alternativa, el modelo no tenía con qué guardar y el usuario recibía
// un "guardado exitosamente" sobre algo que nunca se creó.
func TestExisteHerramientaParaFacturaSinAdjunto(t *testing.T) {
tools := agentTools()
nombres := map[string]agentTool{}
for _, tl := range tools {
nombres[tl.Function.Name] = tl
}
crear, ok := nombres["crear_factura"]
if !ok {
t.Fatal("falta crear_factura: no habría forma de registrar una factura de venta sin archivo adjunto")
}
if _, ok := nombres["adjuntar_factura"]; !ok {
t.Error("adjuntar_factura debe seguir existiendo para el caso con documento")
}
// La descripción tiene que distinguirlas, o el modelo elige la equivocada
// y falla por falta de adjunto — que es justo el problema original.
d := strings.ToLower(crear.Function.Description)
if !strings.Contains(d, "sin archivo adjunto") {
t.Error("la descripción debe dejar claro que no necesita adjunto")
}
if !strings.Contains(d, "adjuntar_factura") {
t.Error("la descripción debe remitir a adjuntar_factura cuando sí hay documento")
}
// cliente_id y monto son obligatorios: sin ellos la factura no sirve.
req := strings.Join(crear.Function.Parameters.Required, ",")
for _, campo := range []string{"cliente_id", "monto"} {
if !strings.Contains(req, campo) {
t.Errorf("%q debería ser obligatorio en crear_factura", campo)
}
}
}