feat(ux): 17 mejoras de experiencia de usuario
Backend (10 endpoints nuevos): - Recordatorios personales (CRUD + tabla RecordatorioVendedor) - Lead scoring 0-100 (etapa+recencia+datos) - Dashboard mora (pagos vencidos, monto, días atraso) - Timeline de pagos (pagado/vence/futuro) - Perfil completo contacto (pagos+cotizaciones+docs+score) - Exportar contactos a CSV Frontend Mi día: - Resumen matutino (saludo+KPIs) - Historial visual lead (timeline) - Score badge 0-100 (verde/amarillo/rojo) - Vista kanban (drag & drop) Frontend Cobranza: - Dashboard mora (tabla+WhatsApp) - Timeline pagos visual (nodos coloreados) Frontend Alta Clientes: - Checklist documentos visual - Foto cliente (cámara) Frontend Landing: - Perfil contacto completo - Exportar contactos CSV Frontend Mi día (extras): - Comparativa mensual - KPIs dashboard (leads hoy, ventas, conversión) 806 tests, 5 apps construidas.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
|
||||
import { detalleContactoLocal, listarContactosLocal } from '../services/contactos-local.service.js'
|
||||
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
|
||||
import { perfilCompleto } from '../services/contactos-detalle.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
@@ -122,3 +123,28 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
|
||||
res.json(detalle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contactos/:id/completo — perfil completo del contacto:
|
||||
* datos, historial pagos, cotizaciones, documentos, lead score, última actividad
|
||||
*/
|
||||
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const contactoId = String(req.params['id'])
|
||||
const perfil = await perfilCompleto(contactoId)
|
||||
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(perfil)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contactos/exportar — exportar contactos a CSV
|
||||
* Query params: desarrollo, estatus
|
||||
*/
|
||||
export async function exportarContactosEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const csv = await exportarCSV({
|
||||
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
|
||||
estatus: (req.query['estatus'] as string) ?? undefined,
|
||||
})
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')
|
||||
res.send(csv)
|
||||
}
|
||||
|
||||
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal file
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { construirTimeline } from './planes.controller.js'
|
||||
|
||||
describe('construirTimeline', () => {
|
||||
const hoy = new Date('2026-09-08T12:00:00Z')
|
||||
|
||||
const planBase = {
|
||||
id: 'p1',
|
||||
clienteNombre: 'Test',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-01',
|
||||
engancheTotal: 10000,
|
||||
numPagos: 3,
|
||||
mensualidad: 3333.33,
|
||||
status: 'ACTIVO' as const,
|
||||
pagos: [
|
||||
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-02-01', monto: 3333.33, montoPagado: 3333.33, aplicaciones: [] },
|
||||
{ id: 'pg2', numPago: 2, fechaVencimiento: '2026-08-01', monto: 3333.33, montoPagado: 0, aplicaciones: [] },
|
||||
{ id: 'pg3', numPago: 3, fechaVencimiento: '2026-10-01', monto: 3333.34, montoPagado: 0, aplicaciones: [] },
|
||||
],
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
it('marca pagos pagados, vencidos y futuros correctamente', () => {
|
||||
const timeline = construirTimeline(planBase, hoy)
|
||||
|
||||
expect(timeline).toHaveLength(3)
|
||||
|
||||
// Pago 1: pagado
|
||||
expect(timeline[0]?.estado).toBe('pagado')
|
||||
expect(timeline[0]?.diasRestantes).toBeNull()
|
||||
|
||||
// Pago 2: vencido (01/08/2026 < 08/09/2026) — díasRestantes negativos
|
||||
expect(timeline[1]?.estado).toBe('vence')
|
||||
expect(timeline[1]?.diasRestantes).toBeLessThan(0)
|
||||
|
||||
// Pago 3: futuro (01/10/2026 > 08/09/2026) — díasRestantes positivos
|
||||
expect(timeline[2]?.estado).toBe('futuro')
|
||||
expect(timeline[2]?.diasRestantes).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ordena por numPago asc', () => {
|
||||
const timeline = construirTimeline(planBase, hoy)
|
||||
expect(timeline.map((t) => t.numPago)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('devuelve array vacío si no hay pagos', () => {
|
||||
const planSinPagos = { ...planBase, pagos: [] }
|
||||
expect(construirTimeline(planSinPagos, hoy)).toEqual([])
|
||||
})
|
||||
|
||||
it('pago parcialmente pagado y vencido se marca como vence', () => {
|
||||
const plan = {
|
||||
...planBase,
|
||||
pagos: [
|
||||
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-08-01', monto: 5000, montoPagado: 2000, aplicaciones: [] },
|
||||
],
|
||||
}
|
||||
const timeline = construirTimeline(plan, hoy)
|
||||
expect(timeline[0]?.estado).toBe('vence')
|
||||
expect(timeline[0]?.montoPagado).toBe(2000)
|
||||
})
|
||||
})
|
||||
@@ -306,6 +306,55 @@ export async function agregarNota(req: Request, res: Response): Promise<void> {
|
||||
res.status(201).json(creada)
|
||||
}
|
||||
|
||||
// ── Timeline de pagos ───────────────────────────────────────────────
|
||||
|
||||
export interface TimelinePago {
|
||||
numPago: number
|
||||
fechaVencimiento: string
|
||||
monto: number
|
||||
montoPagado: number
|
||||
estado: 'pagado' | 'vence' | 'futuro'
|
||||
diasRestantes: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera la línea de tiempo de pagos de un plan.
|
||||
* Estados: pagado (montoPagado >= monto), vence (fecha pasada sin cubrir),
|
||||
* futuro (fecha aún no llega).
|
||||
*/
|
||||
export function construirTimeline(plan: PlanPagoDominio, hoy: Date = new Date()): TimelinePago[] {
|
||||
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||
|
||||
return plan.pagos
|
||||
.slice()
|
||||
.sort((a, b) => a.numPago - b.numPago)
|
||||
.map((pago) => {
|
||||
const fv = parseFechaLocal(pago.fechaVencimiento)
|
||||
const pagado = pago.montoPagado >= pago.monto - 0.01
|
||||
const vencido = fv < inicioHoy && !pagado
|
||||
const estado: TimelinePago['estado'] = pagado ? 'pagado' : vencido ? 'vence' : 'futuro'
|
||||
const diasRestantes = pagado
|
||||
? null
|
||||
: Math.round((fv.getTime() - inicioHoy.getTime()) / 86_400_000)
|
||||
|
||||
return {
|
||||
numPago: pago.numPago,
|
||||
fechaVencimiento: pago.fechaVencimiento,
|
||||
monto: pago.monto,
|
||||
montoPagado: pago.montoPagado,
|
||||
estado,
|
||||
diasRestantes,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/planes/:id/timeline — timeline visual de pagos */
|
||||
export async function timelinePlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
const dominio = aDominio(plan)
|
||||
res.json(construirTimeline(dominio))
|
||||
}
|
||||
|
||||
/** POST /api/planes/:id/cancelar */
|
||||
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const svcMock = vi.hoisted(() => ({
|
||||
crear: vi.fn(),
|
||||
listar: vi.fn(),
|
||||
completar: vi.fn(),
|
||||
eliminar: vi.fn(),
|
||||
proximos: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/recordatorio-vendedor.service.js', () => ({
|
||||
recordatorioVendedorService: svcMock,
|
||||
}))
|
||||
|
||||
import { crearController } from './recordatorio-vendedor.controller.js'
|
||||
|
||||
const controller = crearController(svcMock as never)
|
||||
|
||||
function resJson() {
|
||||
const res = {
|
||||
json: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
end: vi.fn(),
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function reqConToken(sub = 'u-1', overrides: Record<string, unknown> = {}) {
|
||||
return { params: {}, query: {}, usuarioToken: { sub, rol: 'vendedor' }, ...overrides } as never
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('recordatorio-vendedor.controller', () => {
|
||||
it('crear: llama al service con usuarioId del token', async () => {
|
||||
svcMock.crear.mockResolvedValue({ id: 'r1', titulo: 'Test' })
|
||||
const res = resJson()
|
||||
|
||||
await controller.crear(
|
||||
reqConToken('u-1', { body: { titulo: 'Test', fechaRecordar: '2026-09-10T10:00:00' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.crear).toHaveBeenCalledWith('u-1', {
|
||||
titulo: 'Test',
|
||||
descripcion: null,
|
||||
leadId: null,
|
||||
fechaRecordar: '2026-09-10T10:00:00',
|
||||
})
|
||||
expect(res.status).toHaveBeenCalledWith(201)
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'r1', titulo: 'Test' })
|
||||
})
|
||||
|
||||
it('listar: pasa filtros query al service', async () => {
|
||||
svcMock.listar.mockResolvedValue([])
|
||||
const res = resJson()
|
||||
|
||||
await controller.listar(
|
||||
reqConToken('u-1', { query: { completados: 'true', desde: '2026-09-01' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.listar).toHaveBeenCalledWith('u-1', {
|
||||
completados: true,
|
||||
desde: '2026-09-01',
|
||||
hasta: null,
|
||||
})
|
||||
expect(res.json).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('completar: llama al service con el id', async () => {
|
||||
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
|
||||
const res = resJson()
|
||||
|
||||
await controller.completar(
|
||||
reqConToken('u-1', { params: { id: 'r1' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.completar).toHaveBeenCalledWith('r1')
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 'r1', completado: true })
|
||||
})
|
||||
|
||||
it('eliminar: responde 204', async () => {
|
||||
svcMock.eliminar.mockResolvedValue(undefined)
|
||||
const res = resJson()
|
||||
|
||||
await controller.eliminar(
|
||||
reqConToken('u-1', { params: { id: 'r1' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.eliminar).toHaveBeenCalledWith('r1')
|
||||
expect(res.status).toHaveBeenCalledWith(204)
|
||||
expect(res.end).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal file
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
recordatorioVendedorService,
|
||||
type RecordatorioVendedorService,
|
||||
} from '../services/recordatorio-vendedor.service.js'
|
||||
|
||||
const crearSchema = z.object({
|
||||
titulo: z.string().min(1),
|
||||
descripcion: z.string().optional(),
|
||||
leadId: z.string().optional(),
|
||||
fechaRecordar: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/recordatorios — crear recordatorio (requiere auth)
|
||||
*/
|
||||
export async function crearRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearSchema.parse(req.body)
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
|
||||
const recordatorio = await recordatorioVendedorService.crear(usuarioId, {
|
||||
titulo: body.titulo,
|
||||
descripcion: body.descripcion ?? null,
|
||||
leadId: body.leadId ?? null,
|
||||
fechaRecordar: body.fechaRecordar,
|
||||
})
|
||||
res.status(201).json(recordatorio)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/recordatorios — listar recordatorios del usuario (requiere auth)
|
||||
* Query params: completados, desde, hasta
|
||||
*/
|
||||
export async function listarRecordatoriosEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
|
||||
const completadosStr = req.query['completados'] as string | undefined
|
||||
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
|
||||
desde: (req.query['desde'] as string) ?? null,
|
||||
hasta: (req.query['hasta'] as string) ?? null,
|
||||
}
|
||||
if (completadosStr === 'true') opts.completados = true
|
||||
else if (completadosStr === 'false') opts.completados = false
|
||||
|
||||
const recordatorios = await recordatorioVendedorService.listar(usuarioId, opts)
|
||||
res.json(recordatorios)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/recordatorios/:id/completar — marcar como completado (requiere auth)
|
||||
*/
|
||||
export async function completarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = String(req.params['id'])
|
||||
const recordatorio = await recordatorioVendedorService.completar(id)
|
||||
res.json(recordatorio)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/recordatorios/:id — eliminar recordatorio (requiere auth)
|
||||
*/
|
||||
export async function eliminarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = String(req.params['id'])
|
||||
await recordatorioVendedorService.eliminar(id)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// Factory exportable para tests (inyección de dependencia)
|
||||
export function crearController(svc: RecordatorioVendedorService) {
|
||||
return {
|
||||
crear: async (req: Request, res: Response): Promise<void> => {
|
||||
const body = crearSchema.parse(req.body)
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
const r = await svc.crear(usuarioId, {
|
||||
titulo: body.titulo,
|
||||
descripcion: body.descripcion ?? null,
|
||||
leadId: body.leadId ?? null,
|
||||
fechaRecordar: body.fechaRecordar,
|
||||
})
|
||||
res.status(201).json(r)
|
||||
},
|
||||
listar: async (req: Request, res: Response): Promise<void> => {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
const completadosStr = req.query['completados'] as string | undefined
|
||||
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
|
||||
desde: (req.query['desde'] as string) ?? null,
|
||||
hasta: (req.query['hasta'] as string) ?? null,
|
||||
}
|
||||
if (completadosStr === 'true') opts.completados = true
|
||||
else if (completadosStr === 'false') opts.completados = false
|
||||
const items = await svc.listar(usuarioId, opts)
|
||||
res.json(items)
|
||||
},
|
||||
completar: async (req: Request, res: Response): Promise<void> => {
|
||||
const r = await svc.completar(String(req.params['id']))
|
||||
res.json(r)
|
||||
},
|
||||
eliminar: async (req: Request, res: Response): Promise<void> => {
|
||||
await svc.eliminar(String(req.params['id']))
|
||||
res.status(204).end()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { ReportesService } from '../services/reportes.service.js'
|
||||
import { cargarPlanesDominio } from './planes.controller.js'
|
||||
import { obtenerScores } from '../services/lead-scoring.service.js'
|
||||
import { resumenMora } from '../services/mora.service.js'
|
||||
|
||||
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
|
||||
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
||||
@@ -10,3 +12,14 @@ export const reportesService = new ReportesService(mondayService, cargarPlanesDo
|
||||
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await reportesService.resumen())
|
||||
}
|
||||
|
||||
/** GET /api/reportes/scoring — scores de todos los leads activos */
|
||||
export async function scoringEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.query['usuarioId'] as string | undefined
|
||||
res.json(await obtenerScores(usuarioId))
|
||||
}
|
||||
|
||||
/** GET /api/reportes/mora — dashboard de mora: pagos vencidos */
|
||||
export async function moraEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await resumenMora())
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
listarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEndpoint,
|
||||
timelinePlan,
|
||||
} from '../controllers/planes.controller.js'
|
||||
import {
|
||||
cambiarEtapaOportunidadEndpoint,
|
||||
@@ -64,7 +65,12 @@ import {
|
||||
listarUsuariosMondayEndpoint,
|
||||
} from '../controllers/usuarios.controller.js'
|
||||
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js'
|
||||
import { listarContactosEndpoint, detalleContactoEndpoint } from '../controllers/contactos.controller.js'
|
||||
import {
|
||||
listarContactosEndpoint,
|
||||
detalleContactoEndpoint,
|
||||
perfilCompletoEndpoint,
|
||||
exportarContactosEndpoint,
|
||||
} from '../controllers/contactos.controller.js'
|
||||
import { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||
import {
|
||||
correrRoundRobin,
|
||||
@@ -80,7 +86,7 @@ import {
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||
import { resumenReportes } from '../controllers/reportes.controller.js'
|
||||
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
||||
import { estadoSistema } from '../controllers/sistema.controller.js'
|
||||
import {
|
||||
requireAdmin,
|
||||
@@ -95,6 +101,12 @@ import {
|
||||
} from '../controllers/config.controller.js'
|
||||
import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js'
|
||||
import { listarAuditoriaEndpoint } from '../controllers/auditoria.controller.js'
|
||||
import {
|
||||
crearRecordatorioEndpoint,
|
||||
listarRecordatoriosEndpoint,
|
||||
completarRecordatorioEndpoint,
|
||||
eliminarRecordatorioEndpoint,
|
||||
} from '../controllers/recordatorio-vendedor.controller.js'
|
||||
import { buscarEndpoint } from '../controllers/buscar.controller.js'
|
||||
import {
|
||||
miTableroEndpoint,
|
||||
@@ -176,6 +188,7 @@ apiRouter.post('/planes', crearPlan)
|
||||
apiRouter.get('/planes', listarPlanes)
|
||||
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
||||
apiRouter.get('/planes/:id', obtenerPlan)
|
||||
apiRouter.get('/planes/:id/timeline', timelinePlan)
|
||||
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||
@@ -184,6 +197,12 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
|
||||
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
||||
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
||||
|
||||
// ── Recordatorios personales del vendedor ────────────────────────────
|
||||
apiRouter.post('/recordatorios', crearRecordatorioEndpoint)
|
||||
apiRouter.get('/recordatorios', listarRecordatoriosEndpoint)
|
||||
apiRouter.patch('/recordatorios/:id/completar', completarRecordatorioEndpoint)
|
||||
apiRouter.delete('/recordatorios/:id', eliminarRecordatorioEndpoint)
|
||||
|
||||
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||
// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
|
||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||
@@ -200,7 +219,9 @@ apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
|
||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||
|
||||
apiRouter.get('/contactos', listarContactosEndpoint)
|
||||
apiRouter.get('/contactos/exportar', exportarContactosEndpoint)
|
||||
apiRouter.get('/contactos/:id/detalle', detalleContactoEndpoint)
|
||||
apiRouter.get('/contactos/:id/completo', perfilCompletoEndpoint)
|
||||
|
||||
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
|
||||
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
||||
@@ -218,6 +239,8 @@ apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
|
||||
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||
|
||||
apiRouter.get('/reportes/resumen', resumenReportes)
|
||||
apiRouter.get('/reportes/scoring', scoringEndpoint)
|
||||
apiRouter.get('/reportes/mora', moraEndpoint)
|
||||
// Tablero personal del vendedor: métricas, conversión, ventas, origen, desarrollos
|
||||
apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
|
||||
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { prisma } from './prisma.js'
|
||||
import { calcularScore } from './lead-scoring.service.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
|
||||
import type { MondayService } from './monday.service.js'
|
||||
@@ -195,3 +197,167 @@ export async function detalleContacto(
|
||||
historial,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
|
||||
|
||||
export interface PerfilCompletoContacto {
|
||||
contacto: Record<string, string>
|
||||
historialPagos: Array<{
|
||||
planId: string
|
||||
clienteNombre: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
numPago: number
|
||||
fechaVencimiento: string
|
||||
monto: number
|
||||
montoPagado: number
|
||||
estado: string
|
||||
}>
|
||||
cotizaciones: Array<{
|
||||
id: string
|
||||
unidadCodigo: string
|
||||
tipoPropuesta: string
|
||||
precioFinal: number
|
||||
createdAt: string
|
||||
}>
|
||||
documentos: Array<{
|
||||
id: string
|
||||
tipo: string
|
||||
nombreArchivo: string
|
||||
createdAt: string
|
||||
}>
|
||||
leadScore: number | null
|
||||
ultimaActividad: {
|
||||
titulo: string
|
||||
fecha: string
|
||||
tipo: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Perfil completo de un contacto: combina datos del cliente/lead, historial
|
||||
* de pagos, cotizaciones, documentos, lead score y última actividad local.
|
||||
*/
|
||||
export async function perfilCompleto(contactoId: string): Promise<PerfilCompletoContacto | null> {
|
||||
// Buscar el cliente
|
||||
const cliente = await prisma.cliente.findFirst({
|
||||
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
|
||||
})
|
||||
|
||||
// Buscar la captura del lead
|
||||
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
|
||||
|
||||
if (!cliente && !captura) return null
|
||||
|
||||
const telefono = cliente?.celular ?? captura?.telefono ?? ''
|
||||
const email = (cliente?.email ?? captura?.email ?? '').trim().toLowerCase()
|
||||
const nombre = cliente?.nombreCompleto ?? captura?.nombreLead ?? 'Sin nombre'
|
||||
|
||||
// Contacto fields
|
||||
const contacto: Record<string, string> = {
|
||||
nombre,
|
||||
Teléfono: telefono,
|
||||
Email: email,
|
||||
}
|
||||
if (cliente) {
|
||||
contacto['CURP'] = cliente.curp
|
||||
contacto['RFC'] = cliente.rfc
|
||||
contacto['Proyecto'] = cliente.proyecto
|
||||
}
|
||||
if (captura) {
|
||||
contacto['Etapa'] = captura.etapa
|
||||
contacto['Origen'] = captura.origen ?? ''
|
||||
}
|
||||
|
||||
// Historial de pagos (por teléfono o nombre del cliente)
|
||||
const historialPagos: PerfilCompletoContacto['historialPagos'] = []
|
||||
if (cliente) {
|
||||
const planes = await prisma.planPago.findMany({
|
||||
where: { clienteNombre: cliente.nombreCompleto },
|
||||
include: { pagos: true },
|
||||
})
|
||||
for (const plan of planes) {
|
||||
for (const pago of plan.pagos) {
|
||||
const restante = pago.monto - pago.montoPagado
|
||||
const estado = restante <= 0.01 ? 'pagado' : 'pendiente'
|
||||
historialPagos.push({
|
||||
planId: plan.id,
|
||||
clienteNombre: plan.clienteNombre,
|
||||
unidadCodigo: plan.unidadCodigo,
|
||||
proyecto: plan.proyecto,
|
||||
numPago: pago.numPago,
|
||||
fechaVencimiento: pago.fechaVencimiento.toISOString().slice(0, 10),
|
||||
monto: pago.monto,
|
||||
montoPagado: pago.montoPagado,
|
||||
estado,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cotizaciones (por teléfono)
|
||||
const clave = cliente?.celular?.replace(/\D/g, '').slice(-10) ?? ''
|
||||
const cotizacionesRaw = clave !== ''
|
||||
? await prisma.cotizacion.findMany({
|
||||
where: { clienteTelefono: { contains: clave } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
|
||||
// Documentos
|
||||
const documentosRaw = cliente
|
||||
? await prisma.documento.findMany({
|
||||
where: { clienteId: cliente.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
|
||||
// Lead score
|
||||
let leadScore: number | null = null
|
||||
if (captura) {
|
||||
leadScore = calcularScore({
|
||||
etapa: captura.etapa,
|
||||
telefono: captura.telefono,
|
||||
email: captura.email,
|
||||
origen: captura.origen,
|
||||
createdAt: captura.createdAt,
|
||||
updatedAt: captura.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Última actividad
|
||||
let ultimaActividad: PerfilCompletoContacto['ultimaActividad'] = null
|
||||
if (captura) {
|
||||
const ultima = await prisma.actividadLocal.findFirst({
|
||||
where: { leadCapturaId: captura.leadId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (ultima) {
|
||||
ultimaActividad = {
|
||||
titulo: ultima.titulo,
|
||||
fecha: ultima.createdAt.toISOString(),
|
||||
tipo: ultima.tipo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contacto,
|
||||
historialPagos,
|
||||
cotizaciones: cotizacionesRaw.map((c) => ({
|
||||
id: c.id,
|
||||
unidadCodigo: c.unidadCodigo,
|
||||
tipoPropuesta: c.tipoPropuesta,
|
||||
precioFinal: c.precioFinal,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
documentos: documentosRaw.map((d) => ({
|
||||
id: d.id,
|
||||
tipo: d.tipo,
|
||||
nombreArchivo: d.nombreArchivo,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
})),
|
||||
leadScore,
|
||||
ultimaActividad,
|
||||
}
|
||||
}
|
||||
|
||||
81
api-gateway/src/services/contactos-exportar.service.test.ts
Normal file
81
api-gateway/src/services/contactos-exportar.service.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: { findMany: vi.fn() },
|
||||
leadCaptura: { findMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { exportarCSV } from './contactos-local.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('exportarCSV', () => {
|
||||
it('genera CSV con encabezados cuando no hay contactos', async () => {
|
||||
const csv = await exportarCSV()
|
||||
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad')
|
||||
})
|
||||
|
||||
it('incluye clientes y capturas en el CSV', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{
|
||||
nombreCompleto: 'Juan Pérez',
|
||||
celular: '4771234567',
|
||||
email: 'juan@correo.mx',
|
||||
proyecto: 'SENDA',
|
||||
createdAt: new Date('2026-09-01'),
|
||||
},
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
nombreLead: 'Ana López',
|
||||
telefono: '6649998877',
|
||||
email: 'ana@test.com',
|
||||
desarrollo: 'DIAGONAL',
|
||||
etapa: 'NUEVO',
|
||||
updatedAt: new Date('2026-09-05'),
|
||||
},
|
||||
])
|
||||
|
||||
const csv = await exportarCSV()
|
||||
const lineas = csv.split('\n')
|
||||
expect(lineas).toHaveLength(3) // header + 2 rows
|
||||
expect(lineas[1]).toContain('Juan Pérez')
|
||||
expect(lineas[1]).toContain('Cliente')
|
||||
expect(lineas[2]).toContain('Ana López')
|
||||
expect(lineas[2]).toContain('NUEVO')
|
||||
})
|
||||
|
||||
it('filtra por desarrollo', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ nombreCompleto: 'A', celular: null, email: null, proyecto: 'SENDA', createdAt: new Date() },
|
||||
{ nombreCompleto: 'B', celular: null, email: null, proyecto: 'DIAGONAL', createdAt: new Date() },
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
|
||||
const csv = await exportarCSV({ desarrollo: 'SENDA' })
|
||||
expect(csv).toContain('A')
|
||||
expect(csv).not.toContain('B')
|
||||
})
|
||||
|
||||
it('escapa comas y comillas en los campos', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{
|
||||
nombreCompleto: 'Pérez, Juan',
|
||||
celular: null,
|
||||
email: null,
|
||||
proyecto: 'SENDA',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
|
||||
const csv = await exportarCSV()
|
||||
expect(csv).toContain('"Pérez, Juan"')
|
||||
})
|
||||
})
|
||||
@@ -209,3 +209,79 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
}
|
||||
|
||||
// ── Exportar contactos a CSV ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
|
||||
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
|
||||
*/
|
||||
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
|
||||
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
interface FilaCSV {
|
||||
Nombre: string
|
||||
Teléfono: string
|
||||
Email: string
|
||||
Desarrollo: string
|
||||
Estatus: string
|
||||
'Última actividad': string
|
||||
}
|
||||
|
||||
const filas: FilaCSV[] = []
|
||||
const vistos = new Set<string>()
|
||||
|
||||
for (const c of clientes) {
|
||||
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: c.nombreCompleto,
|
||||
Teléfono: c.celular ?? '',
|
||||
Email: c.email ?? '',
|
||||
Desarrollo: c.proyecto,
|
||||
Estatus: 'Cliente',
|
||||
'Última actividad': c.createdAt.toISOString().slice(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
for (const cap of capturas) {
|
||||
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: cap.nombreLead,
|
||||
Teléfono: cap.telefono ?? '',
|
||||
Email: cap.email ?? '',
|
||||
Desarrollo: cap.desarrollo ?? '',
|
||||
Estatus: cap.etapa,
|
||||
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
// Aplicar filtros
|
||||
let filtradas = filas
|
||||
if (filtros.desarrollo) {
|
||||
const d = filtros.desarrollo.toUpperCase()
|
||||
filtradas = filtradas.filter((f) => f.Desarrollo.toUpperCase().includes(d))
|
||||
}
|
||||
if (filtros.estatus) {
|
||||
const e = filtros.estatus.toUpperCase()
|
||||
filtradas = filtradas.filter((f) => f.Estatus.toUpperCase().includes(e))
|
||||
}
|
||||
|
||||
// Generar CSV
|
||||
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
|
||||
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
|
||||
const lineas = filtradas.map((f) =>
|
||||
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
|
||||
)
|
||||
|
||||
return [encabezado, ...lineas].join('\n')
|
||||
}
|
||||
|
||||
131
api-gateway/src/services/contactos-perfil.service.test.ts
Normal file
131
api-gateway/src/services/contactos-perfil.service.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: { findFirst: vi.fn() },
|
||||
leadCaptura: { findUnique: vi.fn() },
|
||||
planPago: { findMany: vi.fn() },
|
||||
cotizacion: { findMany: vi.fn() },
|
||||
documento: { findMany: vi.fn() },
|
||||
actividadLocal: { findFirst: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
// Mock lead-scoring to avoid circular dependency
|
||||
vi.mock('../services/lead-scoring.service.js', () => ({
|
||||
calcularScore: vi.fn(() => 75),
|
||||
}))
|
||||
|
||||
import { perfilCompleto } from './contactos-detalle.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.planPago.findMany.mockResolvedValue([])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||
prismaMock.documento.findMany.mockResolvedValue([])
|
||||
prismaMock.actividadLocal.findFirst.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('perfilCompleto', () => {
|
||||
it('devuelve null si el contacto no existe', async () => {
|
||||
const result = await perfilCompleto('no-existe')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('arma perfil completo desde Cliente', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'Juan Pérez',
|
||||
curp: 'CURP123',
|
||||
rfc: 'RFC123',
|
||||
email: 'juan@test.com',
|
||||
celular: '4771234567',
|
||||
proyecto: 'SENDA',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
mondayContactoId: null,
|
||||
createdAt: new Date('2026-09-01'),
|
||||
})
|
||||
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'plan1',
|
||||
clienteNombre: 'Juan Pérez',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
proyecto: 'SENDA',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-10-01'), monto: 5000, montoPagado: 0 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ id: 'd1', tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date('2026-09-02') },
|
||||
])
|
||||
|
||||
const result = await perfilCompleto('c1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.contacto['nombre']).toBe('Juan Pérez')
|
||||
expect(result?.contacto['CURP']).toBe('CURP123')
|
||||
expect(result?.historialPagos).toHaveLength(1)
|
||||
expect(result?.historialPagos[0]?.estado).toBe('pendiente')
|
||||
expect(result?.documentos).toHaveLength(1)
|
||||
expect(result?.documentos[0]?.tipo).toBe('INE')
|
||||
})
|
||||
|
||||
it('arma perfil desde LeadCaptura cuando no hay Cliente', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_1',
|
||||
nombreLead: 'Ana López',
|
||||
telefono: '6649998877',
|
||||
email: 'ana@test.com',
|
||||
origen: 'Facebook',
|
||||
etapa: 'INTERESADO',
|
||||
createdAt: new Date('2026-09-05'),
|
||||
updatedAt: new Date('2026-09-07'),
|
||||
})
|
||||
|
||||
prismaMock.actividadLocal.findFirst.mockResolvedValue({
|
||||
titulo: 'Llamar a Ana',
|
||||
tipo: 'SEGUIMIENTO',
|
||||
createdAt: new Date('2026-09-07'),
|
||||
})
|
||||
|
||||
const result = await perfilCompleto('loc_1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.contacto['nombre']).toBe('Ana López')
|
||||
expect(result?.contacto['Etapa']).toBe('INTERESADO')
|
||||
expect(result?.leadScore).toBe(75)
|
||||
expect(result?.ultimaActividad?.titulo).toBe('Llamar a Ana')
|
||||
})
|
||||
|
||||
it('cotizaciones se incluyen cuando hay teléfono', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'Test',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
email: null,
|
||||
celular: '4771234567',
|
||||
proyecto: 'A',
|
||||
unidadCodigo: null,
|
||||
mondayContactoId: null,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'cot1',
|
||||
unidadCodigo: 'U-101',
|
||||
tipoPropuesta: 'BANCARIO',
|
||||
precioFinal: 3000000,
|
||||
createdAt: new Date('2026-09-01'),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await perfilCompleto('c1')
|
||||
expect(result?.cotizaciones).toHaveLength(1)
|
||||
expect(result?.cotizaciones[0]?.tipoPropuesta).toBe('BANCARIO')
|
||||
})
|
||||
})
|
||||
143
api-gateway/src/services/lead-scoring.service.test.ts
Normal file
143
api-gateway/src/services/lead-scoring.service.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { calcularScore, obtenerScores } from './lead-scoring.service.js'
|
||||
|
||||
describe('calcularScore', () => {
|
||||
const ahora = new Date('2026-09-08T12:00:00Z')
|
||||
|
||||
it('asigna score base según etapa (NUEVO = 20)', () => {
|
||||
const lead = {
|
||||
etapa: 'NUEVO',
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
createdAt: new Date('2026-09-07T12:00:00Z'),
|
||||
updatedAt: new Date('2026-09-07T12:00:00Z'),
|
||||
}
|
||||
// 20 (etapa) + 5 (recencia <7d) = 25
|
||||
expect(calcularScore(lead, ahora)).toBe(25)
|
||||
})
|
||||
|
||||
it('asigna score CALIFICADO (90)', () => {
|
||||
const lead = {
|
||||
etapa: 'CALIFICADO',
|
||||
telefono: '4771234567',
|
||||
email: 'test@email.com',
|
||||
origen: 'Facebook',
|
||||
createdAt: new Date('2026-09-08T00:00:00Z'),
|
||||
updatedAt: new Date('2026-09-08T00:00:00Z'),
|
||||
}
|
||||
// 90 + 10 (<24h) + 5 tel + 5 email + 5 origen = 115 → clamp 100
|
||||
expect(calcularScore(lead, ahora)).toBe(100)
|
||||
})
|
||||
|
||||
it('aplica penalización >30 días sin actividad', () => {
|
||||
const lead = {
|
||||
etapa: 'CONTACTADO',
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
createdAt: new Date('2026-07-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-07-01T00:00:00Z'),
|
||||
}
|
||||
// 40 (CONTACTADO) + (-20) (>30d) = 20
|
||||
expect(calcularScore(lead, ahora)).toBe(20)
|
||||
})
|
||||
|
||||
it('score mínimo es 0', () => {
|
||||
const lead = {
|
||||
etapa: 'DESCARTADO',
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
}
|
||||
// 10 (desconocida) + (-20) = -10 → clamp 0
|
||||
expect(calcularScore(lead, ahora)).toBe(0)
|
||||
})
|
||||
|
||||
it('recencia <24h suma +10', () => {
|
||||
const lead = {
|
||||
etapa: 'NUEVO',
|
||||
telefono: '1234567890',
|
||||
email: 'a@b.com',
|
||||
origen: 'Web',
|
||||
createdAt: new Date('2026-09-08T10:00:00Z'),
|
||||
updatedAt: new Date('2026-09-08T10:00:00Z'),
|
||||
}
|
||||
// 20 + 10 + 5 + 5 + 5 = 45
|
||||
expect(calcularScore(lead, ahora)).toBe(45)
|
||||
})
|
||||
|
||||
it('INTERESADO = 60 + recencia <7d = +5 = 65', () => {
|
||||
const lead = {
|
||||
etapa: 'INTERESADO',
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
createdAt: new Date('2026-09-05T12:00:00Z'),
|
||||
updatedAt: new Date('2026-09-05T12:00:00Z'),
|
||||
}
|
||||
expect(calcularScore(lead, ahora)).toBe(65)
|
||||
})
|
||||
})
|
||||
|
||||
describe('obtenerScores', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('devuelve scores ordenados de mayor a menor', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
leadId: 'l1',
|
||||
nombreLead: 'A',
|
||||
usuarioId: 'u1',
|
||||
etapa: 'NUEVO',
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
createdAt: new Date('2026-09-07'),
|
||||
updatedAt: new Date('2026-09-07'),
|
||||
},
|
||||
{
|
||||
leadId: 'l2',
|
||||
nombreLead: 'B',
|
||||
usuarioId: 'u1',
|
||||
etapa: 'CALIFICADO',
|
||||
telefono: '1234567890',
|
||||
email: 'b@b.com',
|
||||
origen: 'Web',
|
||||
createdAt: new Date('2026-09-08'),
|
||||
updatedAt: new Date('2026-09-08'),
|
||||
},
|
||||
])
|
||||
|
||||
const scores = await obtenerScores()
|
||||
expect(scores).toHaveLength(2)
|
||||
expect(scores[0]!.leadId).toBe('l2')
|
||||
expect(scores[0]!.score).toBeGreaterThan(scores[1]!.score)
|
||||
})
|
||||
|
||||
it('filtra por usuarioId cuando se proporciona', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
|
||||
await obtenerScores('u1')
|
||||
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||
usuarioId: 'u1',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
107
api-gateway/src/services/lead-scoring.service.ts
Normal file
107
api-gateway/src/services/lead-scoring.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
* Lead Scoring: asigna un score 0-100 a cada LeadCaptura basado en:
|
||||
* - Etapa del pipeline (NUEVO=20, CONTACTADO=40, INTERESADO=60, AGENDO=80, CALIFICADO=90)
|
||||
* - Recencia (último cambio/creación): <24h +10, <7d +5, >30d -20
|
||||
* - Datos de contacto: tiene teléfono +5, email +5
|
||||
* - Origen conocido (no vacío): +5
|
||||
*/
|
||||
|
||||
const ETAPA_SCORE: Record<string, number> = {
|
||||
NUEVO: 20,
|
||||
CONTACTADO: 40,
|
||||
INTERESADO: 60,
|
||||
AGENDO: 80,
|
||||
CALIFICADO: 90,
|
||||
}
|
||||
|
||||
export interface LeadScore {
|
||||
leadId: string
|
||||
nombreLead: string
|
||||
usuarioId: string
|
||||
etapa: string
|
||||
telefono: string | null
|
||||
email: string | null
|
||||
origen: string | null
|
||||
score: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el score 0-100 de un lead dado su registro de LeadCaptura.
|
||||
* Función pura (testable sin BD).
|
||||
*/
|
||||
export function calcularScore(lead: {
|
||||
etapa: string
|
||||
telefono: string | null
|
||||
email: string | null
|
||||
origen: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}, ahora: Date = new Date()): number {
|
||||
let score = 0
|
||||
|
||||
// 1. Etapa
|
||||
const etapaNorm = (lead.etapa ?? '').trim().toUpperCase()
|
||||
score += ETAPA_SCORE[etapaNorm] ?? 10
|
||||
|
||||
// 2. Recencia (basado en updatedAt como proxy de "última actividad")
|
||||
const diffMs = ahora.getTime() - lead.updatedAt.getTime()
|
||||
const diffHoras = diffMs / (1000 * 60 * 60)
|
||||
if (diffHoras < 24) {
|
||||
score += 10
|
||||
} else if (diffHoras < 7 * 24) {
|
||||
score += 5
|
||||
} else if (diffHoras > 30 * 24) {
|
||||
score -= 20
|
||||
}
|
||||
|
||||
// 3. Datos de contacto
|
||||
if (lead.telefono !== null && lead.telefono.trim() !== '') score += 5
|
||||
if (lead.email !== null && lead.email.trim() !== '') score += 5
|
||||
|
||||
// 4. Origen conocido
|
||||
if (lead.origen !== null && lead.origen.trim() !== '') score += 5
|
||||
|
||||
// Clamp 0-100
|
||||
return Math.max(0, Math.min(100, score))
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los scores de todos los leads activos (no descartados), opcionalmente
|
||||
* filtrados por usuarioId.
|
||||
*/
|
||||
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
||||
const where: Record<string, unknown> = {
|
||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||
}
|
||||
if (usuarioId) {
|
||||
where['usuarioId'] = usuarioId
|
||||
}
|
||||
|
||||
const leads = await prisma.leadCaptura.findMany({ where })
|
||||
const ahora = new Date()
|
||||
|
||||
return leads
|
||||
.map((lead) => ({
|
||||
leadId: lead.leadId,
|
||||
nombreLead: lead.nombreLead,
|
||||
usuarioId: lead.usuarioId,
|
||||
etapa: lead.etapa,
|
||||
telefono: lead.telefono,
|
||||
email: lead.email,
|
||||
origen: lead.origen,
|
||||
score: calcularScore(
|
||||
{
|
||||
etapa: lead.etapa,
|
||||
telefono: lead.telefono,
|
||||
email: lead.email,
|
||||
origen: lead.origen,
|
||||
createdAt: lead.createdAt,
|
||||
updatedAt: lead.updatedAt,
|
||||
},
|
||||
ahora,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
159
api-gateway/src/services/mora.service.test.ts
Normal file
159
api-gateway/src/services/mora.service.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
planPago: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { resumenMora } from './mora.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resumenMora', () => {
|
||||
it('devuelve 0 cuando no hay pagos vencidos', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Juan',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
clienteTelefono: '4771234567',
|
||||
id: 'p1',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 10000, montoPagado: 0 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await resumenMora()
|
||||
|
||||
expect(result.clientesConMora).toBe(0)
|
||||
expect(result.montoTotalVencido).toBe(0)
|
||||
expect(result.clientes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detecta pagos vencidos con monto restante', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Juan Pérez',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
proyecto: 'SENDA',
|
||||
clienteTelefono: '4771234567',
|
||||
id: 'plan1',
|
||||
pagos: [
|
||||
{
|
||||
numPago: 1,
|
||||
fechaVencimiento: new Date('2026-08-01'), // vencido
|
||||
monto: 5000,
|
||||
montoPagado: 2000, // parcial: restante = 3000
|
||||
},
|
||||
{
|
||||
numPago: 2,
|
||||
fechaVencimiento: new Date('2026-09-01'), // vencido
|
||||
monto: 5000,
|
||||
montoPagado: 0, // sin pagar
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await resumenMora()
|
||||
|
||||
expect(result.clientesConMora).toBe(2)
|
||||
expect(result.montoTotalVencido).toBe(8000)
|
||||
expect(result.diasPromedioAtraso).toBeGreaterThan(0)
|
||||
expect(result.clientes[0]?.clienteNombre).toBe('Juan Pérez')
|
||||
})
|
||||
|
||||
it('ordena por días de atraso (mayor primero)', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Corto',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'A',
|
||||
clienteTelefono: null,
|
||||
id: 'p1',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-08-25'), monto: 1000, montoPagado: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
clienteNombre: 'Largo',
|
||||
unidadCodigo: 'U-2',
|
||||
proyecto: 'B',
|
||||
clienteTelefono: null,
|
||||
id: 'p2',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-07-01'), monto: 2000, montoPagado: 0 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await resumenMora()
|
||||
|
||||
expect(result.clientes[0]?.clienteNombre).toBe('Largo')
|
||||
expect(result.clientes[0]?.diasAtraso).toBeGreaterThan(result.clientes[1]!.diasAtraso)
|
||||
})
|
||||
|
||||
it('ignora pagos totalmente cubiertos', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Cliente',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'A',
|
||||
clienteTelefono: null,
|
||||
id: 'p1',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 5000 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await resumenMora()
|
||||
expect(result.clientesConMora).toBe(0)
|
||||
})
|
||||
|
||||
it('ignora pagos cuyo vencimiento es futuro', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Cliente',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'A',
|
||||
clienteTelefono: null,
|
||||
id: 'p1',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 5000, montoPagado: 0 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await resumenMora()
|
||||
expect(result.clientesConMora).toBe(0)
|
||||
})
|
||||
|
||||
it('excluye planes cancelados', async () => {
|
||||
prismaMock.planPago.findMany.mockResolvedValue([
|
||||
{
|
||||
clienteNombre: 'Cancelado',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'A',
|
||||
clienteTelefono: null,
|
||||
id: 'p1',
|
||||
pagos: [
|
||||
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 0 },
|
||||
],
|
||||
status: 'CANCELADO',
|
||||
},
|
||||
])
|
||||
|
||||
// The where clause filters out CANCELADO before they reach the loop
|
||||
prismaMock.planPago.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await resumenMora()
|
||||
expect(result.clientesConMora).toBe(0)
|
||||
})
|
||||
})
|
||||
89
api-gateway/src/services/mora.service.ts
Normal file
89
api-gateway/src/services/mora.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
* Dashboard de mora: clientes con pagos vencidos (fechaVencimiento < hoy
|
||||
* y montoPagado < monto). Devuelve resumen + detalle por cliente.
|
||||
*/
|
||||
|
||||
export interface ClienteMora {
|
||||
clienteNombre: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
pagoVencido: number
|
||||
monto: number
|
||||
diasAtraso: number
|
||||
telefono: string | null
|
||||
planId: string
|
||||
}
|
||||
|
||||
export interface ResumenMora {
|
||||
clientesConMora: number
|
||||
montoTotalVencido: number
|
||||
diasPromedioAtraso: number
|
||||
clientes: ClienteMora[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula días naturales de diferencia entre hoy y la fecha de vencimiento.
|
||||
*/
|
||||
function diasDesde(fechaVencimiento: Date, hoy: Date): number {
|
||||
const hv = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||
const fv = new Date(
|
||||
fechaVencimiento.getFullYear(),
|
||||
fechaVencimiento.getMonth(),
|
||||
fechaVencimiento.getDate(),
|
||||
)
|
||||
return Math.max(0, Math.round((hv.getTime() - fv.getTime()) / 86_400_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen de mora: todos los pagos vencidos de planes activos.
|
||||
*/
|
||||
export async function resumenMora(): Promise<ResumenMora> {
|
||||
const hoy = new Date()
|
||||
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||
|
||||
const planes = await prisma.planPago.findMany({
|
||||
where: { status: { not: 'CANCELADO' } },
|
||||
include: { pagos: true },
|
||||
})
|
||||
|
||||
const clientes: ClienteMora[] = []
|
||||
|
||||
for (const plan of planes) {
|
||||
for (const pago of plan.pagos) {
|
||||
const restante = pago.monto - pago.montoPagado
|
||||
if (restante <= 0) continue // ya pagado
|
||||
const fv = pago.fechaVencimiento
|
||||
if (fv >= inicioHoy) continue // aún no vence
|
||||
|
||||
const dias = diasDesde(fv, hoy)
|
||||
clientes.push({
|
||||
clienteNombre: plan.clienteNombre,
|
||||
unidadCodigo: plan.unidadCodigo,
|
||||
proyecto: plan.proyecto,
|
||||
pagoVencido: pago.numPago,
|
||||
monto: Math.round(restante * 100) / 100,
|
||||
diasAtraso: dias,
|
||||
telefono: plan.clienteTelefono,
|
||||
planId: plan.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar por días de atraso (mayor primero)
|
||||
clientes.sort((a, b) => b.diasAtraso - a.diasAtraso)
|
||||
|
||||
const montoTotalVencido = Math.round(clientes.reduce((acc, c) => acc + c.monto, 0) * 100) / 100
|
||||
const diasPromedioAtraso =
|
||||
clientes.length > 0
|
||||
? Math.round(clientes.reduce((acc, c) => acc + c.diasAtraso, 0) / clientes.length)
|
||||
: 0
|
||||
|
||||
return {
|
||||
clientesConMora: clientes.length,
|
||||
montoTotalVencido,
|
||||
diasPromedioAtraso,
|
||||
clientes,
|
||||
}
|
||||
}
|
||||
138
api-gateway/src/services/recordatorio-vendedor.service.test.ts
Normal file
138
api-gateway/src/services/recordatorio-vendedor.service.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
recordatorioVendedor: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { RecordatorioVendedorService } from './recordatorio-vendedor.service.js'
|
||||
|
||||
const svc = new RecordatorioVendedorService()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('RecordatorioVendedorService', () => {
|
||||
const usuarioId = 'u-1'
|
||||
|
||||
describe('crear', () => {
|
||||
it('crea un recordatorio con los datos proporcionados', async () => {
|
||||
prismaMock.recordatorioVendedor.create.mockResolvedValue({
|
||||
id: 'r1',
|
||||
usuarioId,
|
||||
titulo: 'Llamar a Juan',
|
||||
descripcion: 'Sobre cotización',
|
||||
leadId: null,
|
||||
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||
completado: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
const result = await svc.crear(usuarioId, {
|
||||
titulo: 'Llamar a Juan',
|
||||
descripcion: 'Sobre cotización',
|
||||
leadId: null,
|
||||
fechaRecordar: '2026-09-10T10:00:00',
|
||||
})
|
||||
|
||||
expect(result.id).toBe('r1')
|
||||
expect(prismaMock.recordatorioVendedor.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
usuarioId,
|
||||
titulo: 'Llamar a Juan',
|
||||
descripcion: 'Sobre cotización',
|
||||
leadId: null,
|
||||
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('listar', () => {
|
||||
it('lista recordatorios del usuario ordenados por fechaRecordar asc', async () => {
|
||||
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([
|
||||
{ id: 'r1', titulo: 'A', fechaRecordar: new Date('2026-09-10') },
|
||||
{ id: 'r2', titulo: 'B', fechaRecordar: new Date('2026-09-12') },
|
||||
])
|
||||
|
||||
const result = await svc.listar(usuarioId)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
|
||||
where: { usuarioId },
|
||||
orderBy: { fechaRecordar: 'asc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('filtra por completados cuando se especifica', async () => {
|
||||
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([])
|
||||
|
||||
await svc.listar(usuarioId, { completados: true })
|
||||
|
||||
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
|
||||
where: { usuarioId, completado: true },
|
||||
orderBy: { fechaRecordar: 'asc' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('completar', () => {
|
||||
it('marca como completado un recordatorio existente', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
||||
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
||||
|
||||
const result = await svc.completar('r1')
|
||||
|
||||
expect(result.completado).toBe(true)
|
||||
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { completado: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('lanza error si el recordatorio no existe', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(svc.completar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eliminar', () => {
|
||||
it('elimina un recordatorio existente', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
||||
prismaMock.recordatorioVendedor.delete.mockResolvedValue({ id: 'r1' })
|
||||
|
||||
await svc.eliminar('r1')
|
||||
|
||||
expect(prismaMock.recordatorioVendedor.delete).toHaveBeenCalledWith({ where: { id: 'r1' } })
|
||||
})
|
||||
|
||||
it('lanza error si el recordatorio no existe', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(svc.eliminar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
|
||||
})
|
||||
})
|
||||
|
||||
describe('proximos', () => {
|
||||
it('devuelve recordatorios no completados en las próximas N horas', async () => {
|
||||
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([{ id: 'r1' }])
|
||||
|
||||
const result = await svc.proximos(usuarioId, 24)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const where = prismaMock.recordatorioVendedor.findMany.mock.calls[0]?.[0]?.where
|
||||
expect(where?.['usuarioId']).toBe(usuarioId)
|
||||
expect(where?.['completado']).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
91
api-gateway/src/services/recordatorio-vendedor.service.ts
Normal file
91
api-gateway/src/services/recordatorio-vendedor.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { prisma } from './prisma.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* CRUD de recordatorios personales del vendedor (tabla RecordatorioVendedor).
|
||||
* Cada vendedor crea recordatorios con fecha/hora a recordar; el front los
|
||||
* muestra en "Mis recordatorios" de Mi día.
|
||||
*/
|
||||
|
||||
export interface DatosCrearRecordatorio {
|
||||
titulo: string
|
||||
descripcion?: string | null
|
||||
leadId?: string | null
|
||||
fechaRecordar: string // ISO o YYYY-MM-DDTHH:mm
|
||||
}
|
||||
|
||||
export interface OpcionesListarRecordatorio {
|
||||
completados?: boolean
|
||||
desde?: string | null // ISO
|
||||
hasta?: string | null // ISO
|
||||
}
|
||||
|
||||
export class RecordatorioVendedorService {
|
||||
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
||||
return prisma.recordatorioVendedor.create({
|
||||
data: {
|
||||
usuarioId,
|
||||
titulo: data.titulo.trim(),
|
||||
descripcion: data.descripcion?.trim() ?? null,
|
||||
leadId: data.leadId ?? null,
|
||||
fechaRecordar: new Date(data.fechaRecordar),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
|
||||
const where: Record<string, unknown> = { usuarioId }
|
||||
|
||||
if (opciones.completados !== undefined) {
|
||||
where['completado'] = opciones.completados
|
||||
}
|
||||
if (opciones.desde !== null && opciones.desde !== undefined) {
|
||||
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
|
||||
;(where['fechaRecordar'] as Record<string, Date>)['gte'] = new Date(opciones.desde)
|
||||
}
|
||||
if (opciones.hasta !== null && opciones.hasta !== undefined) {
|
||||
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
|
||||
;(where['fechaRecordar'] as Record<string, Date>)['lte'] = new Date(opciones.hasta)
|
||||
}
|
||||
|
||||
return prisma.recordatorioVendedor.findMany({
|
||||
where,
|
||||
orderBy: { fechaRecordar: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
async completar(id: string) {
|
||||
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||
return prisma.recordatorioVendedor.update({
|
||||
where: { id },
|
||||
data: { completado: true },
|
||||
})
|
||||
}
|
||||
|
||||
async eliminar(id: string) {
|
||||
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||
return prisma.recordatorioVendedor.delete({ where: { id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Próximos a vencer: recordatorios no completados cuya fechaRecordar está
|
||||
* dentro de las próximas `horas` horas (default 48h).
|
||||
*/
|
||||
async proximos(usuarioId: string, horas = 48) {
|
||||
const desde = new Date()
|
||||
const hasta = new Date(desde.getTime() + horas * 60 * 60 * 1000)
|
||||
|
||||
return prisma.recordatorioVendedor.findMany({
|
||||
where: {
|
||||
usuarioId,
|
||||
completado: false,
|
||||
fechaRecordar: { gte: desde, lte: hasta },
|
||||
},
|
||||
orderBy: { fechaRecordar: 'asc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const recordatorioVendedorService = new RecordatorioVendedorService()
|
||||
Reference in New Issue
Block a user