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

@@ -69,3 +69,11 @@ AI_MIN_SCORE="0.3"
LEAD_ESCALADO_HORAS=48
# User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado
MONDAY_COORDINADOR_USER_ID=
# Recordatorios de cobranza (pagos por vencer / vencidos)
# Cron del escaneo diario (default "30 9 * * *")
RECORDATORIOS_CRON="30 9 * * *"
# Días de anticipación para el recordatorio "próximo a vencer" (default 3)
RECORDATORIO_DIAS_ANTES=3
# Cada cuántos días se re-recuerda un pago que sigue vencido (default 7)
RECORDATORIO_REVOLVER_DIAS=7

View File

@@ -104,11 +104,15 @@ model PlanPago {
// Item del tablero Apartado (Monday) ligado a este plan; al liquidarse el
// plan, ese item pasa a 'X Escriturar' (puente cobranza → postventa).
mondayItemId String?
// Vendedor dueño del plan (sub del JWT al crearlo); null en planes históricos.
// Alimenta el filtro "mis cobranzas" y la ruta de recordatorios al vendedor.
vendedorId String?
createdAt DateTime @default(now())
notas NotaPlan[]
pagos Pago[]
@@index([unidadCodigo])
@@index([vendedorId])
}
model Pago {
@@ -144,6 +148,23 @@ model AutomatizacionState {
updatedAt DateTime @updatedAt
}
// Bitácora de recordatorios de pago enviados por el motor de cobranza
// (recordatorios-cobranza.service). Sirve a la vez de dedup (no repetir el
// mismo recordatorio el mismo día / respetar la ventana de re-recordatorio) y
// de historial consultable por GET /api/cobranza/recordatorios.
model RecordatorioLog {
id String @id @default(uuid())
pagoId String
tipo String // PROXIMO_VENCER | VENCIDO
canal String // monday-actividad | whatsapp | log
destinatario String // mondayUserId del responsable o 'sin-destinatario'
mensaje String
createdAt DateTime @default(now())
@@index([pagoId])
@@index([createdAt])
}
model NotaPlan {
id String @id @default(uuid())
planId String
@@ -200,8 +221,13 @@ model AIChunk {
model AIConversacion {
id String @id @default(uuid())
// Dueño de la conversación (sub del JWT); null en anónimas/históricas.
// GET /api/ai/conversations devuelve solo las del usuario (admin: todas).
usuarioId String?
createdAt DateTime @default(now())
mensajes AIMensaje[]
@@index([usuarioId])
}
model AIMensaje {

View File

@@ -48,6 +48,14 @@ export const config = {
pollCron: env('POLL_CRON', '*/5 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
},
recordatoriosCobranza: {
// Escaneo diario de pagos por vencer / vencidos (motor de recordatorios)
cron: env('RECORDATORIOS_CRON', '30 9 * * *'),
// Días de anticipación para el recordatorio "próximo a vencer"
diasAntes: Number(env('RECORDATORIO_DIAS_ANTES', '3')),
// Cada cuántos días se re-recuerda un pago que sigue vencido
revolverDias: Number(env('RECORDATORIO_REVOLVER_DIAS', '7')),
},
ai: {
// Proveedor NAN (OpenAI-compatible): chat + embeddings
apiKey: env('NAN_API_KEY'),

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { filtroConversaciones } from './ai.controller.js'
describe('filtroConversaciones (aislamiento por usuario)', () => {
it('cada usuario solo ve sus conversaciones', () => {
expect(filtroConversaciones({ sub: 'u1', rol: 'vendedor' })).toEqual({ usuarioId: 'u1' })
expect(filtroConversaciones({ sub: 'u2', rol: 'coordinador' })).toEqual({ usuarioId: 'u2' })
})
it('admin las ve todas; sin token (AUTH_ENABLED=false) también', () => {
expect(filtroConversaciones({ sub: 'u1', rol: 'admin' })).toBeUndefined()
expect(filtroConversaciones(undefined)).toBeUndefined()
})
})

View File

@@ -12,13 +12,28 @@ const chatSchema = z.object({
/** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */
export async function chatIA(req: Request, res: Response): Promise<void> {
const body = chatSchema.parse(req.body)
const resultado = await chat(body.sessionId ?? null, body.mensaje)
const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, {
usuarioId: req.usuarioToken?.sub ?? null,
})
res.json(resultado)
}
/** GET /api/ai/conversations — listado con último mensaje como preview */
export async function listarConversaciones(_req: Request, res: Response): Promise<void> {
/**
* Filtro "mis conversaciones": cada usuario solo ve las suyas; admin las ve
* todas. Sin token (AUTH_ENABLED=false) se devuelve todo.
*/
export function filtroConversaciones(
token: { sub: string; rol: string } | undefined,
): { usuarioId: string } | undefined {
if (!token || token.rol === 'admin') return undefined
return { usuarioId: token.sub }
}
/** GET /api/ai/conversations — listado con último mensaje como preview (solo las mías) */
export async function listarConversaciones(req: Request, res: Response): Promise<void> {
const where = filtroConversaciones(req.usuarioToken)
const conversaciones = await prisma.aIConversacion.findMany({
...(where ? { where } : {}),
orderBy: { createdAt: 'desc' },
include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } },
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { notificarEscrituracionSiLiquidado } from './planes.controller.js'
import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js'
/** Monday mockeado: registra cambios de columna */
function mondayMock() {
@@ -40,3 +40,23 @@ describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', ()
spyError.mockRestore()
})
})
describe('resolverFiltroMios (?mios=1)', () => {
it('sin ?mios=1 no filtra nada', () => {
expect(resolverFiltroMios(undefined, { sub: 'u1', rol: 'vendedor' })).toBeUndefined()
expect(resolverFiltroMios('0', { sub: 'u1', rol: 'vendedor' })).toBeUndefined()
})
it('vendedor con ?mios=1 → filtra por su sub', () => {
expect(resolverFiltroMios('1', { sub: 'u1', rol: 'vendedor' })).toEqual({ vendedorId: 'u1' })
})
it('admin y coordinador ven todo aunque pidan ?mios=1', () => {
expect(resolverFiltroMios('1', { sub: 'u1', rol: 'admin' })).toBeUndefined()
expect(resolverFiltroMios('1', { sub: 'u1', rol: 'coordinador' })).toBeUndefined()
})
it('sin token (AUTH_ENABLED=false) el filtro no aplica', () => {
expect(resolverFiltroMios('1', undefined)).toBeUndefined()
})
})

View File

@@ -58,6 +58,7 @@ export function aDominio(plan: PlanCompleto): PlanPagoDominio {
tasaMoraMensual: plan.tasaMoraMensual,
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
mondayItemId: plan.mondayItemId,
vendedorId: plan.vendedorId,
createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos
.slice()
@@ -142,6 +143,8 @@ export async function crearPlan(req: Request, res: Response): Promise<void> {
mensualidad: calendario.mensualidad,
tasaMoraMensual: body.tasaMoraMensual ?? 0,
mondayItemId: body.mondayItemId ?? null,
// Dueño del plan: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
vendedorId: req.usuarioToken?.sub ?? null,
pagos: {
create: calendario.pagos.map((p) => ({
numPago: p.numPago,
@@ -155,9 +158,29 @@ export async function crearPlan(req: Request, res: Response): Promise<void> {
res.status(201).json(conCalculados(aDominio(plan)))
}
/** GET /api/planes — con saldo/status calculados */
export async function listarPlanes(_req: Request, res: Response): Promise<void> {
// Roles con vista global de cobranza: el filtro ?mios=1 no les aplica
const ROLES_VEN_TODO: readonly string[] = ['admin', 'coordinador']
/**
* Filtro "mis cobranzas": con ?mios=1 un vendedor solo ve sus planes
* (vendedorId = sub del token). Admin/coordinador ven todo aunque pidan mios;
* sin token (AUTH_ENABLED=false) el filtro no aplica.
*/
export function resolverFiltroMios(
mios: unknown,
token: { sub: string; rol: string } | undefined,
): { vendedorId: string } | undefined {
if (mios !== '1') return undefined
if (!token) return undefined
if (ROLES_VEN_TODO.includes(token.rol)) return undefined
return { vendedorId: token.sub }
}
/** GET /api/planes — con saldo/status calculados; ?mios=1 filtra los del vendedor */
export async function listarPlanes(req: Request, res: Response): Promise<void> {
const where = resolverFiltroMios(req.query['mios'], req.usuarioToken)
const planes = await prisma.planPago.findMany({
...(where ? { where } : {}),
include: includeCompleto,
orderBy: { createdAt: 'desc' },
})

View File

@@ -0,0 +1,31 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js'
import { AppError } from '../utils/errors.js'
// Roles que pueden disparar una pasada manual del motor de recordatorios
const ROLES_RUN: readonly string[] = ['admin', 'cobranza']
/** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */
export async function listarRecordatorios(req: Request, res: Response): Promise<void> {
const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500)
const registros = await prisma.recordatorioLog.findMany({
orderBy: { createdAt: 'desc' },
take: limite,
})
res.json(registros)
}
/**
* POST /api/cobranza/recordatorios/run — pasada manual del motor (admin/cobranza).
* Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir en el log.
*/
export async function correrRecordatorios(req: Request, res: Response): Promise<void> {
const token = req.usuarioToken
// Sin token (AUTH_ENABLED=false) se permite; con token se exige rol admin/cobranza
if (token && !ROLES_RUN.includes(token.rol)) {
throw new AppError(403, 'Solo admin o cobranza pueden ejecutar el motor de recordatorios')
}
const dryRun = req.query['dry'] === '1'
res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun }))
}

View File

@@ -2,6 +2,7 @@ import cron from 'node-cron'
import { crearApp } from './app.js'
import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
const app = crearApp()
@@ -59,6 +60,22 @@ if (config.automatizaciones.enabled) {
// eslint-disable-next-line no-console
console.log(`Automatizaciones Monday activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`)
// Escaneo diario de recordatorios de cobranza (pagos por vencer / vencidos)
cron.schedule(config.recordatoriosCobranza.cron, () => {
recordatoriosCobranzaService
.ejecutarPasada()
.then((r) => {
// eslint-disable-next-line no-console
console.log(
`[cron cobranza] recordatorios: ${r.enviados.length} enviado(s), ` +
`${r.evaluados.filter((e) => e.decision === 'dedup').length} deduplicado(s), ${r.fallidos.length} fallido(s)`,
)
})
.catch((err: unknown) => console.error('[cron cobranza] error en recordatorios:', err))
})
// eslint-disable-next-line no-console
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
}
app.listen(config.port, () => {

View File

@@ -41,6 +41,7 @@ import {
registrarAbonoEndpoint,
} from '../controllers/planes.controller.js'
import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js'
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
import {
cambiarEstadoLeadEndpoint,
crearLeadEndpoint,
@@ -112,6 +113,9 @@ apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)

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 } })
})
})

View File

@@ -2,6 +2,16 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { mondayService } from './monday.service.js'
import {
APARTADO_DEFAULT,
calcularPropuesta,
obtenerPrecioLista,
TIPO_PROPUESTA_API,
totalPagado,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
@@ -10,9 +20,36 @@ export interface MensajeChat {
contenido: string
}
// Mensaje en el formato crudo de la API (incluye tool calls y respuestas de tools)
export interface MensajeApi {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]
tool_call_id?: string
}
export interface ToolCallSolicitado {
id: string
nombre: string
argumentosJson: string
}
export interface RespuestaCruda {
contenido: string | null
toolCalls: ToolCallSolicitado[]
}
/** Definición de herramienta en el formato function-calling de OpenAI */
export interface HerramientaDef {
type: 'function'
function: { name: string; description: string; parameters: Record<string, unknown> }
}
export interface NanClient {
chat(mensajes: MensajeChat[]): Promise<string>
embed(textos: string[]): Promise<number[][]>
/** Chat con herramientas (function calling); opcional para compat con clientes simples */
chatConTools?(mensajes: MensajeApi[], herramientas: HerramientaDef[]): Promise<RespuestaCruda>
}
interface NanOpciones {
@@ -47,6 +84,32 @@ export function crearNanClient(opciones: NanOpciones): NanClient {
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
return contenido
},
async chatConTools(mensajes, herramientas) {
const body: Record<string, unknown> = {
model: opciones.chatModel,
messages: mensajes,
temperature: 0.2,
}
// Sin herramientas registradas el proveedor responde texto plano (cierre del loop)
if (herramientas.length > 0) {
body['tools'] = herramientas
body['tool_choice'] = 'auto'
}
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, body)) as {
choices?: {
message?: {
content?: string | null
tool_calls?: { id: string; type: string; function: { name: string; arguments: string } }[]
}
}[]
}
const message = data.choices?.[0]?.message
if (!message) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
const toolCalls = (message.tool_calls ?? [])
.filter((t) => t.type === 'function')
.map((t) => ({ id: t.id, nombre: t.function.name, argumentosJson: t.function.arguments }))
return { contenido: message.content ?? null, toolCalls }
},
async embed(textos) {
if (textos.length === 0) return []
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
@@ -186,6 +249,13 @@ export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
'- Responde en español, de forma clara y concisa.',
'- Al final de tu respuesta cita las fuentes usadas con el formato: "Fuentes: <título 1>, <título 2>".',
'',
'HERRAMIENTAS:',
'- Tienes herramientas para cotizar una unidad con el motor del cotizador y para buscar unidades',
' disponibles. Úsalas cuando te pidan números o disponibilidad; NUNCA inventes precios ni cifras.',
'- Cuando uses cotizar_unidad, presenta enganche, mensualidades y pago final, y aclara que la',
' cotización es ORIENTATIVA: el PDF oficial se genera en el cotizador (/quote/).',
'- Si la unidad no existe o no está DISPONIBLE, dilo claramente y ofrece buscar alternativas.',
'',
contexto.length > 0
? `CONTEXTO:\n\n${bloques}`
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
@@ -270,6 +340,160 @@ export async function indexarDocumento(
return { titulo, estado: 'creado', chunks: textos.length }
}
// ---------- Herramientas (function calling): cotizar y buscar unidades ----------
const TIPOS_PROPUESTA_VALIDOS = Object.keys(TIPO_PROPUESTA_API) as TipoPropuestaApi[]
export const HERRAMIENTAS: HerramientaDef[] = [
{
type: 'function',
function: {
name: 'cotizar_unidad',
description:
'Cotiza una unidad con el motor del cotizador (enganche, mensualidad, pago final, total). ' +
'Úsala cuando pidan números de una unidad concreta. Requiere el código de la unidad (p. ej. SOLANA-6).',
parameters: {
type: 'object',
properties: {
codigo: { type: 'string', description: 'Código de la unidad, p. ej. SOLANA-6' },
tipoPropuesta: {
type: 'string',
enum: TIPOS_PROPUESTA_VALIDOS,
description: 'Esquema de pago; default BANCARIO',
},
apartado: { type: 'number', description: `Monto del apartado en MXN; default ${APARTADO_DEFAULT}` },
acabados: { type: 'boolean', description: 'Incluir cocina y closet (default false)' },
},
required: ['codigo'],
},
},
},
{
type: 'function',
function: {
name: 'buscar_unidad',
description:
'Busca hasta 5 unidades DISPONIBLE que cumplan los filtros (desarrollo, modelo, precio máximo).',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo, p. ej. SOLANA, DIAGONAL, ATELIER' },
modelo: { type: 'string', description: 'Texto contenido en el nombre del modelo' },
precioMax: { type: 'number', description: 'Precio de lista máximo en MXN (sin acabados)' },
},
},
},
},
]
export interface ArgsCotizarUnidad {
codigo?: string
tipoPropuesta?: string
apartado?: number
acabados?: boolean
}
export interface ArgsBuscarUnidad {
desarrollo?: string
modelo?: string
precioMax?: number
}
export interface EjecutoresHerramientas {
cotizar_unidad(args: ArgsCotizarUnidad): Promise<unknown>
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
}
/** Redondeo monetario a 2 decimales para los números que lee el modelo */
const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100)
/**
* Ejecutores reales: inventario desde Monday (o el buscador inyectado en tests)
* y cálculo con el motor de cotización portado de los Excel.
*/
export function crearEjecutoresHerramientas(
buscarUnidades: () => Promise<UnidadCotizable[]> = () => mondayService.getUnidades(),
): EjecutoresHerramientas {
return {
async cotizar_unidad(args) {
const codigo = (args.codigo ?? '').trim().toUpperCase()
if (codigo === '') return { error: 'Falta el código de la unidad' }
const unidades = await buscarUnidades()
const unidad = unidades.find((u) => u.codigo.toUpperCase() === codigo)
if (!unidad) return { error: `La unidad ${codigo} no existe en el inventario` }
if (unidad.status !== 'DISPONIBLE') {
return { error: `La unidad ${codigo} no está DISPONIBLE (status actual: ${unidad.status})` }
}
const tipo = (args.tipoPropuesta ?? 'BANCARIO').trim().toUpperCase()
if (!TIPOS_PROPUESTA_VALIDOS.includes(tipo as TipoPropuestaApi)) {
return { error: `tipoPropuesta inválido: ${tipo}. Válidos: ${TIPOS_PROPUESTA_VALIDOS.join(', ')}` }
}
const apartado =
typeof args.apartado === 'number' && Number.isFinite(args.apartado) && args.apartado > 0
? args.apartado
: APARTADO_DEFAULT
const acabados = args.acabados === true
const propuesta = calcularPropuesta(unidad, tipo as TipoPropuestaApi, acabados, apartado)
return {
unidad: unidad.codigo,
desarrollo: unidad.desarrollo,
modelo: unidad.modelo,
esquema: propuesta.titulo,
precioLista: r2(obtenerPrecioLista(unidad, acabados)),
descuento: propuesta.descuento,
precioFinal: r2(propuesta.precioFinal),
apartado: r2(apartado),
enganche: r2(propuesta.enganche),
numMensualidades: propuesta.numMensualidades,
mensualidad: r2(propuesta.mensualidad),
mensualidadesTotal: r2(propuesta.mensualidadesTotal),
pagoFinal: r2(propuesta.pagoFinal),
totalPagado: r2(totalPagado(propuesta, apartado)),
nota: 'Cotización orientativa; el PDF oficial se genera en el cotizador (/quote/).',
}
},
async buscar_unidad(args) {
const unidades = await buscarUnidades()
let lista = unidades.filter((u) => u.status === 'DISPONIBLE')
const desarrollo = args.desarrollo?.trim().toUpperCase()
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
const modelo = args.modelo?.trim().toLowerCase()
if (modelo) lista = lista.filter((u) => u.modelo.toLowerCase().includes(modelo))
if (typeof args.precioMax === 'number' && Number.isFinite(args.precioMax)) {
lista = lista.filter((u) => u.precioSinAcabados <= args.precioMax!)
}
return lista.slice(0, 5).map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo,
precio: r2(u.precioSinAcabados),
precioConAcabados: r2(u.precioConAcabados),
supConstruccion: u.supConstruccion,
}))
},
}
}
/** Ejecuta un tool call contra los ejecutores; nunca lanza (el error va al modelo) */
export async function ejecutarToolCall(
call: ToolCallSolicitado,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
let args: Record<string, unknown>
try {
args = JSON.parse(call.argumentosJson || '{}') as Record<string, unknown>
} catch {
return JSON.stringify({ error: `Argumentos inválidos para ${call.nombre}` })
}
const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas]
if (!ejecutor) return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` })
try {
return JSON.stringify(await ejecutor(args))
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
}
}
// ---------- Chat RAG ----------
const HISTORIAL_MAX = 10
@@ -295,16 +519,77 @@ export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
}
export interface OpcionesChat {
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
usuarioId?: string | null
/** Ejecutores de herramientas (default: los reales contra Monday + motor de cotización) */
ejecutores?: EjecutoresHerramientas
}
/** Máximo de rondas de ejecución de herramientas por mensaje */
export const MAX_RONDAS_TOOLS = 2
/**
* Loop de tool calling: si el modelo pide herramientas, se ejecutan y se hace
* una segunda llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después
* se fuerza el cierre sin herramientas). Sin chatConTools en el cliente, chat plano.
*/
async function responderConHerramientas(
mensajes: MensajeApi[],
client: NanClient,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
if (!client.chatConTools) {
// Cliente simple (tests legados): chat plano sin tools
return client.chat(
mensajes
.filter((m) => m.role !== 'tool')
.map((m) => ({
rol: m.role === 'assistant' ? ('assistant' as const) : m.role === 'system' ? ('system' as const) : ('user' as const),
contenido: m.content ?? '',
})),
)
}
let herramientas = HERRAMIENTAS
for (let ronda = 0; ronda <= MAX_RONDAS_TOOLS; ronda++) {
const resp = await client.chatConTools(mensajes, herramientas)
if (resp.toolCalls.length === 0) {
if (resp.contenido) return resp.contenido
throw new AppError(502, 'El proveedor IA no devolvió respuesta')
}
if (ronda === MAX_RONDAS_TOOLS) break // no debería pasar: la última llamada va sin tools
mensajes.push({
role: 'assistant',
content: resp.contenido,
tool_calls: resp.toolCalls.map((t) => ({
id: t.id,
type: 'function' as const,
function: { name: t.nombre, arguments: t.argumentosJson },
})),
})
for (const call of resp.toolCalls) {
const resultado = await ejecutarToolCall(call, ejecutores)
mensajes.push({ role: 'tool', tool_call_id: call.id, content: resultado })
}
if (ronda === MAX_RONDAS_TOOLS - 1) herramientas = [] // cierre: forzar texto plano
}
throw new AppError(502, 'El proveedor IA no cerró la respuesta tras usar herramientas')
}
export async function chat(
sessionId: string | null,
mensaje: string,
client: NanClient = clienteNan(),
opciones: OpcionesChat = {},
): Promise<RespuestaChat> {
let conversacion = sessionId
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
conversacion ??= await prisma.aIConversacion.create({ data: {} })
conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null },
})
await prisma.aIMensaje.create({
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
@@ -334,12 +619,16 @@ export async function chat(
orderBy: { createdAt: 'asc' },
take: -HISTORIAL_MAX,
})
const mensajes: MensajeChat[] = [
{ rol: 'system', contenido: construirSystemPrompt(top) },
...historial.map((m) => ({ rol: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), contenido: m.contenido })),
const mensajes: MensajeApi[] = [
{ role: 'system', content: construirSystemPrompt(top) },
...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })),
]
const respuesta = await client.chat(mensajes)
const respuesta = await responderConHerramientas(
mensajes,
client,
opciones.ejecutores ?? crearEjecutoresHerramientas(),
)
const fuentes = dedupeFuentes(top)
await prisma.aIMensaje.create({

View File

@@ -46,6 +46,8 @@ export interface PlanPago {
status: StatusPlan
/** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */
mondayItemId?: string | null
/** Vendedor dueño del plan (sub del JWT); null en planes históricos */
vendedorId?: string | null
pagos: Pago[]
notas: NotaPlan[]
createdAt: string // ISO

View File

@@ -0,0 +1,281 @@
import { describe, expect, it, vi } from 'vitest'
import type { Pago, PlanPago } from './cobranza.service.js'
import {
MondayActividadCanal,
RecordatoriosCobranzaService,
WhatsAppCanal,
candidatosRecordatorio,
debeEnviar,
diasHastaVencimiento,
elegirDestinatario,
type CanalRecordatorio,
type DestinatarioRecordatorio,
type RecordatorioEnviado,
type RecordatoriosStore,
} from './recordatorios-cobranza.service.js'
// ---------- Fixtures ----------
function pago(parcial: Partial<Pago> & { id: string; fechaVencimiento: string }): Pago {
return {
numPago: 1,
monto: 10000,
montoPagado: 0,
aplicaciones: [],
...parcial,
}
}
function plan(parcial: Partial<PlanPago> & { id: string; pagos: Pago[] }): PlanPago {
return {
clienteNombre: 'Cliente Demo',
unidadCodigo: 'SOLANA-6',
proyecto: 'SOLANA',
fechaInicio: '2026-01-15',
engancheTotal: 100000,
numPagos: 10,
mensualidad: 10000,
status: 'ACTIVO',
vendedorId: null,
notas: [],
createdAt: '2026-01-15T00:00:00.000Z',
...parcial,
}
}
/** Suma/resta días a una fecha y devuelve 'YYYY-MM-DD' */
function isoMasDias(base: Date, dias: number): string {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
const HOY = new Date(2026, 7, 28, 12, 0, 0) // 28-ago-2026 al mediodía
// ---------- Fechas ----------
describe('diasHastaVencimiento', () => {
it('0 si vence hoy (sin importar la hora), negativo si ya pasó', () => {
expect(diasHastaVencimiento('2026-08-28', HOY)).toBe(0)
expect(diasHastaVencimiento('2026-08-31', HOY)).toBe(3)
expect(diasHastaVencimiento('2026-08-25', HOY)).toBe(-3)
})
})
// ---------- Detección de candidatos ----------
describe('candidatosRecordatorio', () => {
it('pago que vence dentro de la ventana → PROXIMO_VENCER con los días correctos', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r).toHaveLength(1)
expect(r[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', dias: 2, montoPendiente: 10000 })
expect(r[0]!.mensaje).toContain('vence en 2 día(s)')
})
it('pago que vence hoy → PROXIMO_VENCER con "vence HOY"', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 0) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'PROXIMO_VENCER', dias: 0 })
expect(r[0]!.mensaje).toContain('vence HOY')
})
it('pago fuera de la ventana no genera recordatorio', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 5) })] })]
expect(candidatosRecordatorio(planes, HOY, 3)).toHaveLength(0)
})
it('pago vencido → VENCIDO con días de atraso', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, -5) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'VENCIDO', dias: -5 })
expect(r[0]!.mensaje).toContain('VENCIDO hace 5 día(s)')
})
it('pago PARCIAL vencido cuenta como VENCIDO con el monto pendiente', () => {
const planes = [
plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, -2), montoPagado: 4000 })] }),
]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'VENCIDO', montoPendiente: 6000 })
})
it('ignora pagos PAGADO y planes CANCELADO', () => {
const planes = [
plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 1), montoPagado: 10000 })] }),
plan({ id: 'p2', status: 'CANCELADO', pagos: [pago({ id: 'pg2', fechaVencimiento: isoMasDias(HOY, -10) })] }),
]
expect(candidatosRecordatorio(planes, HOY, 3)).toHaveLength(0)
})
})
// ---------- Dedup ----------
describe('debeEnviar', () => {
it('nunca enviado → sí; mismo día → no (ambos tipos)', () => {
expect(debeEnviar(null, 'VENCIDO', HOY, 7)).toBe(true)
const hoyTemprano = new Date(2026, 7, 28, 1, 0, 0)
expect(debeEnviar(hoyTemprano, 'VENCIDO', HOY, 7)).toBe(false)
expect(debeEnviar(hoyTemprano, 'PROXIMO_VENCER', HOY, 7)).toBe(false)
})
it('PROXIMO_VENCER en otro día → sí (la fecha se acerca)', () => {
const ayer = new Date(2026, 7, 27, 9, 0, 0)
expect(debeEnviar(ayer, 'PROXIMO_VENCER', HOY, 7)).toBe(true)
})
it('VENCIDO: solo re-recuerda cuando pasaron ≥ revolverDias', () => {
const hace3 = new Date(2026, 7, 25, 9, 0, 0)
const hace7 = new Date(2026, 7, 21, 9, 0, 0)
expect(debeEnviar(hace3, 'VENCIDO', HOY, 7)).toBe(false)
expect(debeEnviar(hace7, 'VENCIDO', HOY, 7)).toBe(true)
})
})
// ---------- Selección de destinatario ----------
describe('elegirDestinatario', () => {
it('vendedor mapeado → vendedor; si no, coordinador; si no, ninguno', () => {
expect(elegirDestinatario(123, 999)).toEqual({ mondayUserId: 123, origen: 'vendedor' })
expect(elegirDestinatario(null, 999)).toEqual({ mondayUserId: 999, origen: 'coordinador' })
expect(elegirDestinatario(null, 0)).toEqual({ mondayUserId: null, origen: 'ninguno' })
})
})
// ---------- Canales ----------
describe('canales', () => {
it('MondayActividadCanal solo está disponible con mondayUserId y crea la actividad', async () => {
const creadas: { nombre: string; responsableId: number }[] = []
const canal = new MondayActividadCanal({
async crearActividad(datos) {
creadas.push({ nombre: datos.nombre, responsableId: datos.responsableId })
return 'item-1'
},
})
expect(canal.disponible({ mondayUserId: null, origen: 'ninguno' })).toBe(false)
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(true)
const id = await canal.enviar({ mondayUserId: 42, origen: 'vendedor' }, 'mensaje de prueba', '2026-08-28')
expect(id).toBe('item-1')
expect(creadas).toEqual([{ nombre: 'mensaje de prueba', responsableId: 42 }])
})
it('WhatsAppCanal es un stub inactivo: nunca disponible y solo loguea', async () => {
const canal = new WhatsAppCanal()
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(false)
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
await expect(canal.enviar()).resolves.toBe('pendiente-wa-crm')
expect(spy).toHaveBeenCalledWith(expect.stringContaining('pendiente WA CRM'))
spy.mockRestore()
})
})
// ---------- Pasada del motor (store y canal inyectados) ----------
/** Canal fake que captura envíos; solo disponible con destinatario (como Monday) */
function canalFake() {
const envios: { destinatario: DestinatarioRecordatorio; mensaje: string }[] = []
const canal: CanalRecordatorio = {
nombre: 'fake',
disponible: (d) => d.mondayUserId !== null,
async enviar(destinatario, mensaje) {
envios.push({ destinatario, mensaje })
return 'ok'
},
}
return { canal, envios }
}
function storeFake(planes: PlanPago[], mondayId: number | null) {
const log: RecordatorioEnviado[] = []
const ultimos = new Map<string, Date>()
const store: RecordatoriosStore = {
cargarPlanes: async () => planes,
mondayUserIdDeVendedor: async () => mondayId,
ultimoRecordatorio: async (pagoId, tipo) => ultimos.get(`${pagoId}:${tipo}`) ?? null,
registrarLog: async (r) => {
log.push(r)
ultimos.set(`${r.pagoId}:${r.tipo}`, HOY)
},
}
return { store, log }
}
describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
const planes = [
plan({
id: 'p1',
vendedorId: 'vend-1',
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
}),
]
it('dry-run: lista lo que enviaría sin enviar ni escribir en el log', async () => {
const { store, log } = storeFake(planes, 555)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r = await svc.ejecutarPasada({ dryRun: true, ahora: HOY })
expect(r.dryRun).toBe(true)
expect(r.evaluados).toHaveLength(1)
expect(r.evaluados[0]).toMatchObject({
pagoId: 'pg1',
decision: 'enviado',
destinatario: { mondayUserId: 555, origen: 'vendedor' },
})
expect(envios).toHaveLength(0)
expect(log).toHaveLength(0)
expect(r.enviados).toHaveLength(0)
})
it('pasada real: envía por el canal, registra el log y la segunda pasada deduplica', async () => {
const { store, log } = storeFake(planes, 555)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r1 = await svc.ejecutarPasada({ ahora: HOY })
expect(r1.enviados).toHaveLength(1)
expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: '555' })
expect(envios).toHaveLength(1)
expect(log).toHaveLength(1)
// Segunda pasada el mismo día: dedup, nada nuevo
const r2 = await svc.ejecutarPasada({ ahora: HOY })
expect(r2.enviados).toHaveLength(0)
expect(r2.evaluados[0]).toMatchObject({ decision: 'dedup' })
expect(envios).toHaveLength(1)
expect(log).toHaveLength(1)
})
it('sin destinatario (sin mapeo ni coordinador): solo registra en el log, sin canal', async () => {
const { store, log } = storeFake(planes, null)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
// config de test no define MONDAY_COORDINADOR_USER_ID → 0 → 'ninguno'
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(1)
expect(r.enviados[0]).toMatchObject({ canal: 'log', destinatario: 'sin-destinatario' })
expect(envios).toHaveLength(0)
expect(log).toHaveLength(1)
})
it('un fallo del canal no escribe en el log (reintentable) y se reporta en fallidos', async () => {
const { store, log } = storeFake(planes, 555)
const canalRoto: CanalRecordatorio = {
nombre: 'roto',
disponible: () => true,
enviar: async () => {
throw new Error('Monday caído')
},
}
const svc = new RecordatoriosCobranzaService(store, [canalRoto])
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(0)
expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'Monday caído' }])
expect(log).toHaveLength(0)
})
})

View File

@@ -0,0 +1,345 @@
import { config } from '../config/index.js'
import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from './cobranza.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Motor de recordatorios de pago de la cobranza.
*
* Escaneo diario (cron en index.ts) sobre los planes ACTIVO:
* - pagos que vencen en ≤ RECORDATORIO_DIAS_ANTES días → "próximo a vencer"
* - pagos vencidos → "vencido", re-recordatorio cada RECORDATORIO_REVOLVER_DIAS
*
* Canal actual: Actividad en Monday al vendedor del plan (si tiene
* mondayUserId; si no, al coordinador de MONDAY_COORDINADOR_USER_ID; si no,
* solo se registra en el log). La interfaz CanalRecordatorio permite enchufar
* WhatsApp después (stub WhatsAppCanal incluido, inactivo hasta el WA CRM).
*
* Dedup e historial: tabla RecordatorioLog (no se repite el mismo recordatorio
* el mismo día; los vencidos respetan la ventana de re-recordatorio).
*/
export type TipoRecordatorio = 'PROXIMO_VENCER' | 'VENCIDO'
const DIA_MS = 24 * 60 * 60 * 1000
// ---------- Detección de candidatos (lógica pura, testeable sin DB ni red) ----------
export interface CandidatoRecordatorio {
pagoId: string
numPago: number
tipo: TipoRecordatorio
planId: string
clienteNombre: string
unidadCodigo: string
proyecto: string
vendedorId: string | null
fechaVencimiento: string // 'YYYY-MM-DD'
montoPendiente: number
/** Días hasta el vencimiento (negativo = días de atraso) */
dias: number
mensaje: string
}
const medianoche = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate())
/** Días enteros de `hoy` al vencimiento (0 = vence hoy, negativo = vencido) */
export function diasHastaVencimiento(fechaVencimiento: string, hoy: Date): number {
return Math.round((parseFechaLocal(fechaVencimiento).getTime() - medianoche(hoy).getTime()) / DIA_MS)
}
const formatoDinero = (n: number): string =>
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
function mensajeRecordatorio(c: Omit<CandidatoRecordatorio, 'mensaje'>): string {
const base =
`Cobranza ${c.unidadCodigo} (${c.clienteNombre}) — pago ${c.numPago} ` +
`por ${formatoDinero(c.montoPendiente)} con vencimiento ${c.fechaVencimiento}`
if (c.tipo === 'PROXIMO_VENCER') {
return c.dias === 0 ? `${base}: vence HOY.` : `${base}: vence en ${c.dias} día(s).`
}
return `${base}: VENCIDO hace ${Math.abs(c.dias)} día(s). Favor de cobrar o reagendar.`
}
/**
* Pagos de planes ACTIVO que ameritan recordatorio hoy:
* - PROXIMO_VENCER: no cubierto y vence entre hoy y hoy+diasAntes
* - VENCIDO: no cubierto y la fecha ya pasó (incluye PARCIAL vencido)
*/
export function candidatosRecordatorio(
planes: PlanPago[],
hoy: Date,
diasAntes: number,
): CandidatoRecordatorio[] {
const candidatos: CandidatoRecordatorio[] = []
for (const plan of planes) {
if (plan.status === 'CANCELADO') continue
for (const pago of plan.pagos) {
const status = statusPago(pago, hoy)
if (status === 'PAGADO') continue
const dias = diasHastaVencimiento(pago.fechaVencimiento, hoy)
let tipo: TipoRecordatorio | null = null
if (dias < 0) tipo = 'VENCIDO'
else if (dias <= diasAntes) tipo = 'PROXIMO_VENCER'
if (!tipo) continue
const base: Omit<CandidatoRecordatorio, 'mensaje'> = {
pagoId: pago.id,
numPago: pago.numPago,
tipo,
planId: plan.id,
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
vendedorId: plan.vendedorId ?? null,
fechaVencimiento: pago.fechaVencimiento,
montoPendiente: round2(pago.monto - pago.montoPagado),
dias,
}
candidatos.push({ ...base, mensaje: mensajeRecordatorio(base) })
}
}
return candidatos
}
// ---------- Dedup (lógica pura sobre el último recordatorio registrado) ----------
const esMismoDia = (a: Date, b: Date): boolean => medianoche(a).getTime() === medianoche(b).getTime()
/**
* ¿Toca enviar el recordatorio dado el último registrado?
* - nunca enviado → sí
* - mismo día → no (dedup diario, ambos tipos)
* - PROXIMO_VENCER en otro día → sí (la fecha de vencimiento se acerca)
* - VENCIDO → solo si pasaron ≥ revolverDias desde el último
*/
export function debeEnviar(
ultimo: Date | null,
tipo: TipoRecordatorio,
ahora: Date,
revolverDias: number,
): boolean {
if (!ultimo) return true
if (esMismoDia(ultimo, ahora)) return false
if (tipo === 'PROXIMO_VENCER') return true
return ahora.getTime() - ultimo.getTime() >= revolverDias * DIA_MS
}
// ---------- Destinatario ----------
export interface DestinatarioRecordatorio {
/** mondayUserId del responsable de la actividad; null = no hay a quién asignar */
mondayUserId: number | null
origen: 'vendedor' | 'coordinador' | 'ninguno'
}
/** Regla pura de selección: vendedor mapeado → coordinador (env) → nadie */
export function elegirDestinatario(
mondayUserIdVendedor: number | null,
coordinadorUserId: number,
): DestinatarioRecordatorio {
if (mondayUserIdVendedor !== null) return { mondayUserId: mondayUserIdVendedor, origen: 'vendedor' }
if (Number.isInteger(coordinadorUserId) && coordinadorUserId > 0) {
return { mondayUserId: coordinadorUserId, origen: 'coordinador' }
}
return { mondayUserId: null, origen: 'ninguno' }
}
// ---------- Canales ----------
export interface CanalRecordatorio {
readonly nombre: string
/** true si el canal puede entregar a este destinatario */
disponible(destinatario: DestinatarioRecordatorio): boolean
/** Entrega el recordatorio; devuelve un detalle (p. ej. el item id de Monday) */
enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string>
}
type MondayActividades = Pick<MondayService, 'crearActividad'>
/** Canal actual: Actividad en Monday asignada al responsable (tablero Actividades) */
export class MondayActividadCanal implements CanalRecordatorio {
readonly nombre = 'monday-actividad'
constructor(private readonly monday: MondayActividades) {}
disponible(destinatario: DestinatarioRecordatorio): boolean {
return destinatario.mondayUserId !== null
}
async enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string> {
return this.monday.crearActividad({
nombre: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje,
tipoLabel: 'Tarea',
responsableId: destinatario.mondayUserId!,
fecha,
estadoLabel: 'Abierto',
})
}
}
/**
* Stub del canal WhatsApp: inactivo hasta que exista la integración del WA CRM.
* `disponible` siempre false (nunca se elige); `enviar` solo loguea.
*/
export class WhatsAppCanal implements CanalRecordatorio {
readonly nombre = 'whatsapp'
disponible(): boolean {
return false
}
async enviar(): Promise<string> {
// eslint-disable-next-line no-console
console.log('[recordatorios] canal WhatsApp pendiente WA CRM: no se envió nada')
return 'pendiente-wa-crm'
}
}
// ---------- Servicio ----------
export interface RecordatorioEnviado {
pagoId: string
tipo: TipoRecordatorio
canal: string
destinatario: string
mensaje: string
}
export interface CandidatoEvaluado extends CandidatoRecordatorio {
destinatario: DestinatarioRecordatorio
/** 'enviado' | 'dedup' (ya se recordó dentro de la ventana) */
decision: 'enviado' | 'dedup'
}
export interface ResultadoPasada {
dryRun: boolean
evaluados: CandidatoEvaluado[]
enviados: RecordatorioEnviado[]
fallidos: { pagoId: string; error: string }[]
}
/** Acceso a datos inyectable (Prisma en prod, stubs en tests) */
export interface RecordatoriosStore {
/** Planes ACTIVO con pagos y aplicaciones, en forma de dominio */
cargarPlanes(): Promise<PlanPago[]>
/** mondayUserId del vendedor; null si no existe o no está mapeado */
mondayUserIdDeVendedor(vendedorId: string): Promise<number | null>
/** Fecha del último recordatorio registrado para ese pago y tipo */
ultimoRecordatorio(pagoId: string, tipo: TipoRecordatorio): Promise<Date | null>
registrarLog(r: RecordatorioEnviado): Promise<void>
}
const hoyISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
export class RecordatoriosCobranzaService {
constructor(
private readonly store: RecordatoriosStore,
private readonly canales: CanalRecordatorio[],
) {}
/**
* Una pasada del motor. Con `dryRun: true` no envía nada ni escribe en el
* log: solo devuelve lo que enviaría (para POST .../run?dry=1).
*/
async ejecutarPasada(opciones: { dryRun?: boolean; ahora?: Date } = {}): Promise<ResultadoPasada> {
const dryRun = opciones.dryRun ?? false
const ahora = opciones.ahora ?? new Date()
const { diasAntes, revolverDias } = config.recordatoriosCobranza
const planes = await this.store.cargarPlanes()
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
for (const c of candidatos) {
const mondayVendedor = c.vendedorId ? await this.store.mondayUserIdDeVendedor(c.vendedorId) : null
const destinatario = elegirDestinatario(mondayVendedor, config.automatizaciones.coordinadorUserId)
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
resultado.evaluados.push({ ...c, destinatario, decision: 'dedup' })
continue
}
resultado.evaluados.push({ ...c, destinatario, decision: 'enviado' })
if (dryRun) continue
const canal = this.canales.find((k) => k.disponible(destinatario))
const enviado: RecordatorioEnviado = {
pagoId: c.pagoId,
tipo: c.tipo,
canal: canal?.nombre ?? 'log',
destinatario:
destinatario.mondayUserId !== null ? String(destinatario.mondayUserId) : 'sin-destinatario',
mensaje: c.mensaje,
}
try {
if (canal) await canal.enviar(destinatario, c.mensaje, hoyISO(ahora))
await this.store.registrarLog(enviado)
resultado.enviados.push(enviado)
} catch (err) {
// Un fallo del canal NO queda en el log: la siguiente pasada reintenta
resultado.fallidos.push({ pagoId: c.pagoId, error: err instanceof Error ? err.message : String(err) })
}
}
return resultado
}
}
// ---------- Cableado con Prisma + Monday (singleton para crons y endpoints) ----------
export const prismaRecordatoriosStore: RecordatoriosStore = {
async cargarPlanes() {
const planes = await prisma.planPago.findMany({
where: { status: 'ACTIVO' },
include: { pagos: { include: { aplicaciones: true } } },
})
return planes.map((plan) => ({
id: plan.id,
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
fechaInicio: hoyISO(plan.fechaInicio),
engancheTotal: plan.engancheTotal,
numPagos: plan.numPagos,
mensualidad: plan.mensualidad,
status: 'ACTIVO',
vendedorId: plan.vendedorId,
notas: [],
createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos.map(
(p): Pago => ({
id: p.id,
numPago: p.numPago,
fechaVencimiento: hoyISO(p.fechaVencimiento),
monto: p.monto,
montoPagado: p.montoPagado,
aplicaciones: p.aplicaciones.map((a) => ({
id: a.id,
fechaPago: a.fechaPago.toISOString(),
monto: a.monto,
referencia: a.referencia,
})),
}),
),
}))
},
async mondayUserIdDeVendedor(vendedorId) {
const usuario = await prisma.usuario.findUnique({
where: { id: vendedorId },
select: { mondayUserId: true },
})
return usuario?.mondayUserId ?? null
},
async ultimoRecordatorio(pagoId, tipo) {
const ultimo = await prisma.recordatorioLog.findFirst({
where: { pagoId, tipo },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
})
return ultimo?.createdAt ?? null
},
async registrarLog(r) {
await prisma.recordatorioLog.create({ data: r })
},
}
/** Singleton usado por el cron y los endpoints (los tests instancian el suyo) */
export const recordatoriosCobranzaService = new RecordatoriosCobranzaService(prismaRecordatoriosStore, [
new MondayActividadCanal(mondayService),
new WhatsAppCanal(),
])

View File

@@ -14,7 +14,8 @@ import {
YAxis,
} from 'recharts'
import type { StatusPlan } from '../types'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository'
import { getSesion } from '../services/auth'
import {
alCorriente,
avancePlan,
@@ -55,11 +56,17 @@ export default function DashboardPage() {
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
// Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const hoy = useMemo(() => new Date(), [])
useEffect(() => {
let activo = true
listarPlanes()
setResultado(null)
setErrorCarga(null)
listarPlanes(undefined, mios)
.then((r) => {
if (activo) setResultado(r)
})
@@ -69,7 +76,7 @@ export default function DashboardPage() {
return () => {
activo = false
}
}, [])
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
@@ -287,6 +294,29 @@ export default function DashboardPage() {
{/* Filtros */}
<div className="flex flex-wrap items-center gap-4">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
<div className="flex border border-neutral-300" role="group" aria-label="Alcance de planes">
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
aria-pressed={mios}
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
mios ? 'bg-accent text-white' : 'bg-white text-neutral-600 hover:text-neutral-900'
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
aria-pressed={!mios}
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
mios ? 'bg-white text-neutral-600 hover:text-neutral-900' : 'bg-accent text-white'
}`}
>
Todos
</button>
</div>
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>

View File

@@ -335,3 +335,44 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(guardados[0].status).toBe('CANCELADO')
})
})
describe('mis cobranzas (toggle Mis planes / Todos)', () => {
it('rutaListadoPlanes agrega ?mios=1 solo cuando se piden los míos', async () => {
const { rutaListadoPlanes } = await import('./repository')
expect(rutaListadoPlanes(true)).toBe('/planes?mios=1')
expect(rutaListadoPlanes(false)).toBe('/planes')
})
it('miosPorDefecto: vendedor arranca en "Mis planes"; el resto en "Todos"', async () => {
const { miosPorDefecto } = await import('./repository')
expect(miosPorDefecto('vendedor')).toBe(true)
expect(miosPorDefecto('admin')).toBe(false)
expect(miosPorDefecto('coordinador')).toBe(false)
expect(miosPorDefecto('corporativo')).toBe(false)
expect(miosPorDefecto(null)).toBe(false)
expect(miosPorDefecto(undefined)).toBe(false)
})
it('listarPlanes con mios=true llama al API con ?mios=1', async () => {
const fetchMock = fetchQue(async () => respuestaJson([planApi()]))
vi.stubGlobal('fetch', fetchMock)
const r = await listarPlanes(crearStorageStub(), true)
expect(r.origen).toBe('api')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes?mios=1',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
it('listarPlanes con mios=true cae a localStorage igual que sin el filtro', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const r = await listarPlanes(crearStorageStub(), true)
expect(r.origen).toBe('local') // el respaldo local no tiene vendedorId
expect(r.planes.length).toBeGreaterThan(0)
})
})

View File

@@ -172,14 +172,34 @@ function planesLocalesSinSembrar(storage: StorageLike): PlanPago[] {
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
}
/**
* Ruta del listado de planes: con `mios` agrega ?mios=1 (el API filtra por el
* sub del token; admin/coordinador ven todo de todos modos).
*/
export function rutaListadoPlanes(mios: boolean): string {
return mios ? '/planes?mios=1' : '/planes'
}
/**
* Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en
* "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos".
*/
export function miosPorDefecto(rol: string | null | undefined): boolean {
return rol === 'vendedor'
}
/**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente
* (NO se migran automáticamente) y lo señala en `planesSoloLocales`.
* `mios` solo afecta la llamada al API; el respaldo local no tiene vendedorId.
*/
export async function listarPlanes(storage: StorageLike = storageDefault()): Promise<ResultadoPlanes> {
export async function listarPlanes(
storage: StorageLike = storageDefault(),
mios = false,
): Promise<ResultadoPlanes> {
try {
const planes = (await request<PlanApi[]>('/planes')).map(desdeApi)
const planes = (await request<PlanApi[]>(rutaListadoPlanes(mios))).map(desdeApi)
if (planes.length === 0) {
const locales = planesLocalesSinSembrar(storage)
if (locales.length > 0) {