From aa27e89c75f7621be9dfb3a36507128720fc646f Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 28 Aug 2026 08:07:12 +0000 Subject: [PATCH] feat: mis cobranzas, recordatorios de pago y agente IA con acciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 8 + api-gateway/prisma/schema.prisma | 26 ++ api-gateway/src/config/index.ts | 8 + .../src/controllers/ai.controller.test.ts | 14 + api-gateway/src/controllers/ai.controller.ts | 21 +- .../src/controllers/planes.controller.test.ts | 22 +- .../src/controllers/planes.controller.ts | 27 +- .../controllers/recordatorios.controller.ts | 31 ++ api-gateway/src/index.ts | 17 + api-gateway/src/routes/index.ts | 4 + api-gateway/src/services/ai.service.test.ts | 248 ++++++++++++- api-gateway/src/services/ai.service.ts | 299 ++++++++++++++- api-gateway/src/services/cobranza.service.ts | 2 + .../recordatorios-cobranza.service.test.ts | 281 ++++++++++++++ .../recordatorios-cobranza.service.ts | 345 ++++++++++++++++++ apps/cobranza/src/pages/DashboardPage.tsx | 36 +- .../src/services/repository.api.test.ts | 41 +++ apps/cobranza/src/services/repository.ts | 24 +- 18 files changed, 1437 insertions(+), 17 deletions(-) create mode 100644 api-gateway/src/controllers/ai.controller.test.ts create mode 100644 api-gateway/src/controllers/recordatorios.controller.ts create mode 100644 api-gateway/src/services/recordatorios-cobranza.service.test.ts create mode 100644 api-gateway/src/services/recordatorios-cobranza.service.ts diff --git a/.env.example b/.env.example index ad9578c..1e963bc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index d8a4617..b31b3e8 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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 { diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 73e09da..9bde5f1 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -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'), diff --git a/api-gateway/src/controllers/ai.controller.test.ts b/api-gateway/src/controllers/ai.controller.test.ts new file mode 100644 index 0000000..75cea27 --- /dev/null +++ b/api-gateway/src/controllers/ai.controller.test.ts @@ -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() + }) +}) diff --git a/api-gateway/src/controllers/ai.controller.ts b/api-gateway/src/controllers/ai.controller.ts index 26ebef4..44f190c 100644 --- a/api-gateway/src/controllers/ai.controller.ts +++ b/api-gateway/src/controllers/ai.controller.ts @@ -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 { 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 { +/** + * 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 { + 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 } } }, }) diff --git a/api-gateway/src/controllers/planes.controller.test.ts b/api-gateway/src/controllers/planes.controller.test.ts index 0fdc3b8..0134d5e 100644 --- a/api-gateway/src/controllers/planes.controller.test.ts +++ b/api-gateway/src/controllers/planes.controller.test.ts @@ -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() + }) +}) diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index 836a6c0..f12a028 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -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 { 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 { res.status(201).json(conCalculados(aDominio(plan))) } -/** GET /api/planes — con saldo/status calculados */ -export async function listarPlanes(_req: Request, res: Response): Promise { +// 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 { + const where = resolverFiltroMios(req.query['mios'], req.usuarioToken) const planes = await prisma.planPago.findMany({ + ...(where ? { where } : {}), include: includeCompleto, orderBy: { createdAt: 'desc' }, }) diff --git a/api-gateway/src/controllers/recordatorios.controller.ts b/api-gateway/src/controllers/recordatorios.controller.ts new file mode 100644 index 0000000..2ceb529 --- /dev/null +++ b/api-gateway/src/controllers/recordatorios.controller.ts @@ -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 { + 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 { + 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 })) +} diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index 3e207fb..5ecb61d 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -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, () => { diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 6c7327a..c8da20c 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/ai.service.test.ts b/api-gateway/src/services/ai.service.test.ts index 931a2a2..2619f60 100644 --- a/api-gateway/src/services/ai.service.test.ts +++ b/api-gateway/src/services/ai.service.test.ts @@ -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 + 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 } }) + }) +}) diff --git a/api-gateway/src/services/ai.service.ts b/api-gateway/src/services/ai.service.ts index 2f74c71..191b3e9 100644 --- a/api-gateway/src/services/ai.service.ts +++ b/api-gateway/src/services/ai.service.ts @@ -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 } +} + export interface NanClient { chat(mensajes: MensajeChat[]): Promise embed(textos: string[]): Promise + /** Chat con herramientas (function calling); opcional para compat con clientes simples */ + chatConTools?(mensajes: MensajeApi[], herramientas: HerramientaDef[]): Promise } 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 = { + 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: , ".', '', + '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 + buscar_unidad(args: ArgsBuscarUnidad): Promise +} + +/** 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 = () => 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 { + let args: Record + try { + args = JSON.parse(call.argumentosJson || '{}') as Record + } 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 { + 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 { 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({ diff --git a/api-gateway/src/services/cobranza.service.ts b/api-gateway/src/services/cobranza.service.ts index cada832..ce2b2bd 100644 --- a/api-gateway/src/services/cobranza.service.ts +++ b/api-gateway/src/services/cobranza.service.ts @@ -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 diff --git a/api-gateway/src/services/recordatorios-cobranza.service.test.ts b/api-gateway/src/services/recordatorios-cobranza.service.test.ts new file mode 100644 index 0000000..b4e8758 --- /dev/null +++ b/api-gateway/src/services/recordatorios-cobranza.service.test.ts @@ -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 & { id: string; fechaVencimiento: string }): Pago { + return { + numPago: 1, + monto: 10000, + montoPagado: 0, + aplicaciones: [], + ...parcial, + } +} + +function plan(parcial: Partial & { 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() + 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) + }) +}) diff --git a/api-gateway/src/services/recordatorios-cobranza.service.ts b/api-gateway/src/services/recordatorios-cobranza.service.ts new file mode 100644 index 0000000..5ae339d --- /dev/null +++ b/api-gateway/src/services/recordatorios-cobranza.service.ts @@ -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): 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 = { + 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 +} + +type MondayActividades = Pick + +/** 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 { + 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 { + // 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 + /** mondayUserId del vendedor; null si no existe o no está mapeado */ + mondayUserIdDeVendedor(vendedorId: string): Promise + /** Fecha del último recordatorio registrado para ese pago y tipo */ + ultimoRecordatorio(pagoId: string, tipo: TipoRecordatorio): Promise + registrarLog(r: RecordatorioEnviado): Promise +} + +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 { + 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(), +]) diff --git a/apps/cobranza/src/pages/DashboardPage.tsx b/apps/cobranza/src/pages/DashboardPage.tsx index bdbd187..bffc4a1 100644 --- a/apps/cobranza/src/pages/DashboardPage.tsx +++ b/apps/cobranza/src/pages/DashboardPage.tsx @@ -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 */}
+ {/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */} +
+ + +
diff --git a/apps/cobranza/src/services/repository.api.test.ts b/apps/cobranza/src/services/repository.api.test.ts index 3b93e11..7774c61 100644 --- a/apps/cobranza/src/services/repository.api.test.ts +++ b/apps/cobranza/src/services/repository.api.test.ts @@ -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) + }) +}) diff --git a/apps/cobranza/src/services/repository.ts b/apps/cobranza/src/services/repository.ts index 78c62bd..322e5b3 100644 --- a/apps/cobranza/src/services/repository.ts +++ b/apps/cobranza/src/services/repository.ts @@ -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 { +export async function listarPlanes( + storage: StorageLike = storageDefault(), + mios = false, +): Promise { try { - const planes = (await request('/planes')).map(desdeApi) + const planes = (await request(rutaListadoPlanes(mios))).map(desdeApi) if (planes.length === 0) { const locales = planesLocalesSinSembrar(storage) if (locales.length > 0) {