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:
@@ -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 } })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user