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

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