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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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 }))
|
||||
}
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
281
api-gateway/src/services/recordatorios-cobranza.service.test.ts
Normal file
281
api-gateway/src/services/recordatorios-cobranza.service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
345
api-gateway/src/services/recordatorios-cobranza.service.ts
Normal file
345
api-gateway/src/services/recordatorios-cobranza.service.ts
Normal 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(),
|
||||
])
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user