feat: mis cobranzas, recordatorios de pago y agente IA con acciones

- PlanPago.vendedorId + filtro ?mios=1; toggle Mis planes/Todos en /pay
- Motor de recordatorios: próximos a vencer (3d) y vencidos (re-cada 7d),
  canal Monday Actividad + stub WhatsApp listo para WA CRM, log persistente,
  cron diario 9:30, run manual con dry-run, historial en /api/cobranza/recordatorios
- Agente IA: memoria por usuario (conversaciones aisladas) y tool calling:
  cotizar_unidad (motor real por desarrollo) y buscar_unidad (inventario Monday)
- Tests: api 261, cobranza 75, agente-ia 13
This commit is contained in:
2026-08-28 08:07:12 +00:00
parent 59944668a8
commit aa27e89c75
18 changed files with 1437 additions and 17 deletions

View File

@@ -1,16 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NanClient } from './ai.service.js'
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
import {
HERRAMIENTAS,
chat,
construirSystemPrompt,
crearEjecutoresHerramientas,
decidirIndexacion,
dedupeFuentes,
ejecutarToolCall,
fragmentar,
hashContenido,
indexarDocumento,
rankearChunks,
similitudCoseno,
type ArgsBuscarUnidad,
type ArgsCotizarUnidad,
} from './ai.service.js'
import { calcularPropuesta, obtenerPrecioLista, totalPagado, type UnidadCotizable } from './cotizacion.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
@@ -277,3 +283,243 @@ describe('dedupeFuentes', () => {
])
})
})
// ---------- Herramientas: cotizar_unidad / buscar_unidad ----------
const unidadSolana: UnidadCotizable = {
codigo: 'SOLANA-6',
modelo: 'Modelo A',
fechaEntrega: null,
supEstacionamiento: 12,
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 2_000_000,
precioConAcabados: 2_100_000,
status: 'DISPONIBLE',
desarrollo: 'SOLANA',
mesesParaFirma: 12,
etapa: null,
}
const unidadApartada: UnidadCotizable = { ...unidadSolana, codigo: 'SOLANA-7', status: 'APARTADO' }
const unidadDiagonal: UnidadCotizable = {
...unidadSolana,
codigo: 'DIA-101',
modelo: 'Loft 1R',
desarrollo: 'DIAGONAL',
precioSinAcabados: 2_850_000,
fechaEntrega: '2027-06-30',
mesesParaFirma: null,
}
function ejecutoresCon(unidades: UnidadCotizable[]) {
return crearEjecutoresHerramientas(async () => unidades)
}
describe('crearEjecutoresHerramientas', () => {
it('cotizar_unidad devuelve los números del motor para una unidad DISPONIBLE', async () => {
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({ codigo: 'solana-6' })) as Record<string, unknown>
const esperada = calcularPropuesta(unidadSolana, 'BANCARIO', false, 20000)
expect(r['error']).toBeUndefined()
expect(r['unidad']).toBe('SOLANA-6')
expect(r['enganche']).toBeCloseTo(esperada.enganche, 2)
expect(r['mensualidad']).toBeCloseTo(esperada.mensualidad!, 2)
expect(r['pagoFinal']).toBeCloseTo(esperada.pagoFinal, 2)
expect(r['precioLista']).toBe(obtenerPrecioLista(unidadSolana, false))
expect(r['totalPagado']).toBeCloseTo(totalPagado(esperada, 20000)!, 2)
expect(String(r['nota'])).toContain('orientativa')
})
it('cotizar_unidad: unidad inexistente o no DISPONIBLE devuelve error claro', async () => {
const e = ejecutoresCon([unidadApartada])
expect(await e.cotizar_unidad({ codigo: 'NOPE-1' })).toEqual({
error: 'La unidad NOPE-1 no existe en el inventario',
})
const r = (await e.cotizar_unidad({ codigo: 'SOLANA-7' })) as { error: string }
expect(r.error).toContain('no está DISPONIBLE')
expect(r.error).toContain('APARTADO')
})
it('cotizar_unidad: tipoPropuesta inválido devuelve error con los válidos', async () => {
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({
codigo: 'SOLANA-6',
tipoPropuesta: 'leasing',
})) as { error: string }
expect(r.error).toContain('tipoPropuesta inválido')
expect(r.error).toContain('BANCARIO')
})
it('buscar_unidad filtra por desarrollo, modelo y precioMax; solo DISPONIBLE, máx 5', async () => {
const muchas = Array.from({ length: 8 }, (_, i) => ({
...unidadSolana,
codigo: `SOLANA-${i + 1}`,
}))
const e = ejecutoresCon([unidadSolana, unidadApartada, unidadDiagonal, ...muchas])
// solo DISPONIBLE y tope de 5
const todas = (await e.buscar_unidad({})) as { codigo: string }[]
expect(todas).toHaveLength(5)
expect(todas.every((u) => u.codigo !== 'SOLANA-7')).toBe(true)
// por desarrollo
const diagonal = (await e.buscar_unidad({ desarrollo: 'diagonal' })) as { codigo: string }[]
expect(diagonal.map((u) => u.codigo)).toEqual(['DIA-101'])
// por modelo (texto contenido, case-insensitive)
const loft = (await e.buscar_unidad({ modelo: 'loft' })) as { codigo: string }[]
expect(loft.map((u) => u.codigo)).toEqual(['DIA-101'])
// por precio máximo (sin acabados)
const baratas = (await e.buscar_unidad({ precioMax: 2_500_000 })) as { precio: number }[]
expect(baratas.length).toBeGreaterThan(0)
expect(baratas.every((u) => u.precio <= 2_500_000)).toBe(true)
})
})
describe('ejecutarToolCall', () => {
const ejecutores = {
async cotizar_unidad(args: ArgsCotizarUnidad) {
return { ok: true, args }
},
async buscar_unidad(_args: ArgsBuscarUnidad) {
throw new Error('Monday caído')
},
}
it('parsea los argumentos JSON y devuelve el resultado serializado', async () => {
const r = await ejecutarToolCall(
{ id: 't1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6"}' },
ejecutores,
)
expect(JSON.parse(r)).toEqual({ ok: true, args: { codigo: 'SOLANA-6' } })
})
it('argumentos inválidos, herramienta desconocida o error del ejecutor van como JSON de error', async () => {
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'cotizar_unidad', argumentosJson: '{mal' }, ejecutores)))
.toHaveProperty('error')
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'borrar_todo', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Herramienta desconocida: borrar_todo' })
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'buscar_unidad', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Monday caído' })
})
})
// ---------- Chat con tool calling ----------
/** Cliente NAN falso con function calling: devuelve las respuestas guionizadas en orden */
function clienteConTools(respuestas: RespuestaCruda[]) {
const llamadas: { mensajes: MensajeApi[]; herramientas: HerramientaDef[] }[] = []
const client: NanClient & { llamadas: typeof llamadas } = {
llamadas,
async embed(textos: string[]) {
return textos.map((t) => [t.length, 1, 1])
},
async chat() {
return 'chat plano'
},
async chatConTools(mensajes, herramientas) {
// snapshot: el array de mensajes se muta entre rondas
llamadas.push({ mensajes: mensajes.map((m) => ({ ...m })), herramientas })
return respuestas[llamadas.length - 1] ?? { contenido: 'fin', toolCalls: [] }
},
}
return client
}
function mocksPrismaChat() {
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-tools', createdAt: new Date() })
prismaMock.aIChunk.findMany.mockResolvedValue([])
prismaMock.aIMensaje.findMany.mockResolvedValue([])
prismaMock.aIMensaje.create.mockResolvedValue({})
}
describe('chat con herramientas (tool calling)', () => {
it('ejecuta el tool pedido y hace la segunda llamada con el resultado', async () => {
mocksPrismaChat()
const client = clienteConTools([
{
contenido: null,
toolCalls: [{ id: 'call-1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6","tipoPropuesta":"BANCARIO"}' }],
},
{ contenido: 'La SOLANA-6 queda con enganche de $80,000…', toolCalls: [] },
])
const argsRecibidos: ArgsCotizarUnidad[] = []
const ejecutores = {
async cotizar_unidad(args: ArgsCotizarUnidad) {
argsRecibidos.push(args)
return { enganche: 80000, mensualidad: 8333.33 }
},
async buscar_unidad() {
return []
},
}
const r = await chat(null, 'cotízame la SOLANA-6 a bancario', client, { ejecutores })
expect(r.respuesta).toContain('80,000')
expect(client.llamadas).toHaveLength(2)
// primera llamada: con las herramientas registradas
expect(client.llamadas[0]!.herramientas).toEqual(HERRAMIENTAS)
// el ejecutor recibió los argumentos parseados
expect(argsRecibidos).toEqual([{ codigo: 'SOLANA-6', tipoPropuesta: 'BANCARIO' }])
// segunda llamada: historial con el tool_call del asistente y la respuesta del tool
const msgs2 = client.llamadas[1]!.mensajes
const asistente = msgs2.find((m) => m.role === 'assistant' && m.tool_calls)
expect(asistente?.tool_calls?.[0]).toMatchObject({ id: 'call-1', function: { name: 'cotizar_unidad' } })
const toolMsg = msgs2.find((m) => m.role === 'tool')
expect(toolMsg?.tool_call_id).toBe('call-1')
expect(JSON.parse(toolMsg!.content!)).toMatchObject({ enganche: 80000 })
// se persisten solo user/assistant (los tools no van a la BD)
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
expect(roles).toEqual(['user', 'assistant'])
})
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
mocksPrismaChat()
const toolCall = (id: string): RespuestaCruda => ({
contenido: null,
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
})
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }])
const ejecutores = {
async cotizar_unidad() {
return {}
},
async buscar_unidad() {
return [{ codigo: 'SOLANA-6' }]
},
}
const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
expect(client.llamadas[2]!.herramientas).toEqual([])
expect(r.respuesta).toBe('cierre forzado')
})
it('respuesta directa sin tools: una sola llamada', async () => {
mocksPrismaChat()
const client = clienteConTools([{ contenido: 'Respuesta directa.', toolCalls: [] }])
const r = await chat(null, 'hola', client, {
ejecutores: { cotizar_unidad: async () => ({}), buscar_unidad: async () => [] },
})
expect(client.llamadas).toHaveLength(1)
expect(r.respuesta).toBe('Respuesta directa.')
})
})
// ---------- Memoria por usuario ----------
describe('chat: memoria por usuario', () => {
it('la conversación nueva queda ligada al usuarioId del token', async () => {
mocksPrismaChat()
const client = clienteFalso()
await chat(null, 'hola', client, { usuarioId: 'user-123' })
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: 'user-123' } })
prismaMock.aIConversacion.create.mockClear()
await chat(null, 'hola', client)
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: null } })
})
})