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:
14
api-gateway/src/controllers/ai.controller.test.ts
Normal file
14
api-gateway/src/controllers/ai.controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 } } },
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
31
api-gateway/src/controllers/recordatorios.controller.ts
Normal file
31
api-gateway/src/controllers/recordatorios.controller.ts
Normal 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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user