feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
This commit is contained in:
95
api-gateway/src/services/auditoria.service.test.ts
Normal file
95
api-gateway/src/services/auditoria.service.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
auditoriaLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { registrar, buscar } from './auditoria.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('registrar', () => {
|
||||
it('fire-and-forget: llama a prisma.create sin await', () => {
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
// registrar no devuelve promesa (fire-and-forget)
|
||||
registrar('u1', 'CREAR_LEAD', 'lead', 'lead-1', 'detalle opcional')
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: { usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'lead-1', detalle: 'detalle opcional' },
|
||||
})
|
||||
})
|
||||
|
||||
it('maneja detalle undefined como null', () => {
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
registrar('u1', 'ACCION', 'ent', 'ent-1')
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: { usuarioId: 'u1', accion: 'ACCION', entidad: 'ent', entidadId: 'ent-1', detalle: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('no lanza error si la inserción falla (fire-and-forget)', async () => {
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
prismaMock.auditoriaLog.create.mockRejectedValue(new Error('DB error'))
|
||||
// registrar no lanza
|
||||
registrar('u1', 'ACCION', 'ent', 'ent-1')
|
||||
// Esperar microtask para que el .catch se ejecute
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(spyError).toHaveBeenCalled()
|
||||
spyError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buscar', () => {
|
||||
it('sin filtros: retorna todos los logs ordenados por createdAt desc', async () => {
|
||||
const logsFake = [{ id: 'log-1' }]
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
|
||||
|
||||
const resultado = await buscar({})
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
expect(resultado).toEqual(logsFake)
|
||||
})
|
||||
|
||||
it('con filtros de entidad y usuarioId', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
await buscar({ entidad: 'lead', usuarioId: 'u1' })
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||
where: { entidad: 'lead', usuarioId: 'u1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('con filtros de fecha', async () => {
|
||||
const desde = new Date('2026-09-01')
|
||||
const hasta = new Date('2026-09-07')
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
await buscar({ desde, hasta })
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||
where: { createdAt: { gte: desde, lte: hasta } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('respeta el límite personalizado', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
await buscar({ entidad: 'lead' }, 10)
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||
where: { entidad: 'lead' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
49
api-gateway/src/services/auditoria.service.ts
Normal file
49
api-gateway/src/services/auditoria.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export interface FiltrosAuditoria {
|
||||
entidad?: string
|
||||
entidadId?: string
|
||||
usuarioId?: string
|
||||
desde?: Date
|
||||
hasta?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra un evento de auditoría. Fire-and-forget: no devuelve promesa
|
||||
*awaitable para que el caller no se bloquee.
|
||||
*/
|
||||
export function registrar(
|
||||
usuarioId: string,
|
||||
accion: string,
|
||||
entidad: string,
|
||||
entidadId: string,
|
||||
detalle?: string,
|
||||
): void {
|
||||
// Fire-and-forget: errors se loguean pero no se propagan
|
||||
prisma.auditoriaLog
|
||||
.create({
|
||||
data: { usuarioId, accion, entidad, entidadId, detalle: detalle ?? null },
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[auditoria] Error registrando log:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
|
||||
const where: Record<string, unknown> = {}
|
||||
if (filtros.entidad !== undefined) where['entidad'] = filtros.entidad
|
||||
if (filtros.entidadId !== undefined) where['entidadId'] = filtros.entidadId
|
||||
if (filtros.usuarioId !== undefined) where['usuarioId'] = filtros.usuarioId
|
||||
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
||||
where['createdAt'] = {
|
||||
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
|
||||
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
|
||||
}
|
||||
}
|
||||
return prisma.auditoriaLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './monday.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
import { saludMonday } from './salud-monday.service.js'
|
||||
import { enviarPush, usuarioIdPorMondayUserId } from './push.service.js'
|
||||
|
||||
/**
|
||||
* Automatizaciones sobre Monday:
|
||||
@@ -382,6 +383,11 @@ export class AutomatizacionesService {
|
||||
leadItemId: c.id,
|
||||
})
|
||||
}
|
||||
// Push notification al vendedor asignado
|
||||
const usuarioId = await usuarioIdPorMondayUserId(vendedor.id)
|
||||
if (usuarioId) {
|
||||
void enviarPush(usuarioId, 'Nuevo lead asignado', `${c.nombre} — ${c.tablero}`, `/hoy/`)
|
||||
}
|
||||
detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero })
|
||||
indice++
|
||||
}
|
||||
@@ -700,6 +706,8 @@ export class AutomatizacionesService {
|
||||
}
|
||||
|
||||
if (usuarioLocalId) {
|
||||
// Push notification al vendedor asignado
|
||||
void enviarPush(usuarioLocalId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/')
|
||||
await prisma.asignacionLocal.create({
|
||||
data: {
|
||||
leadCapturaId: lead.id,
|
||||
@@ -859,6 +867,41 @@ export class AutomatizacionesService {
|
||||
return { horasUmbral, candidatos, escalados }
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaneo de apartados por vencer: detecta apartados con fechaLimite en los
|
||||
* próximos 3 días y envía push al vendedor. Dedup diario vía
|
||||
* `apartado-push:<id>:<fecha>`.
|
||||
*/
|
||||
async escaneoApartadosPorVencer(): Promise<{ revisados: number; enviados: number }> {
|
||||
const ahora = new Date()
|
||||
const limite = new Date(ahora)
|
||||
limite.setDate(limite.getDate() + 3)
|
||||
|
||||
const apartados = await prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
board: 'APARTADO',
|
||||
etapa: { notIn: ['Cancelacion Apartado', 'Firma Cliente (Venta)'] },
|
||||
fechaLimite: { not: null, lte: limite, gte: ahora },
|
||||
},
|
||||
})
|
||||
|
||||
let enviados = 0
|
||||
for (const a of apartados) {
|
||||
const clave = `apartado-push:${a.id}:${ahora.toISOString().slice(0, 10)}`
|
||||
if ((await this.state.get(clave)) !== null) continue
|
||||
void enviarPush(
|
||||
a.usuarioId,
|
||||
'Apartado por vencer',
|
||||
`${a.nombreLead} — vence ${a.fechaLimite?.toISOString().slice(0, 10) ?? 'pronto'}`,
|
||||
'/hoy/',
|
||||
)
|
||||
await this.state.set(clave, ahora.toISOString())
|
||||
enviados++
|
||||
}
|
||||
|
||||
return { revisados: apartados.length, enviados }
|
||||
}
|
||||
|
||||
/** Estado actual para el endpoint admin */
|
||||
async estado(): Promise<Record<string, unknown>> {
|
||||
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
|
||||
|
||||
163
api-gateway/src/services/buscar.service.test.ts
Normal file
163
api-gateway/src/services/buscar.service.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: { findMany: vi.fn() },
|
||||
cliente: { findMany: vi.fn() },
|
||||
cotizacion: { findMany: vi.fn() },
|
||||
unidad: { findMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { buscar } from './buscar.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('buscar (GET /api/buscar)', () => {
|
||||
it('término vacío retorna [] sin consultar la base', async () => {
|
||||
const r = await buscar('')
|
||||
expect(r).toEqual([])
|
||||
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('término con solo espacios también retorna []', async () => {
|
||||
const r = await buscar(' ')
|
||||
expect(r).toEqual([])
|
||||
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consulta los 4 modelos con el patrón contains', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{ leadId: 'l1', nombreLead: 'María López', telefono: '4771112233', email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'c1', nombreCompleto: 'María López', curp: '', rfc: '', email: 'maria@x.com', celular: '4771112233', proyecto: 'DIAGONAL', createdAt: new Date() },
|
||||
])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
|
||||
const r = await buscar('María')
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.cliente.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.cotizacion.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.unidad.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(r.length).toBe(2)
|
||||
expect(r[0]?.tipo).toBe('lead')
|
||||
expect(r[1]?.tipo).toBe('contacto')
|
||||
})
|
||||
|
||||
it('aplica take: limite por modelo', async () => {
|
||||
await buscar('test', 5)
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 5 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('prioriza leads sobre contactos, contactos sobre oportunidades, oportunidades sobre unidades', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
|
||||
])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ id: 'u1', codigo: 'X', desarrollo: 'SENDA', modelo: null, status: null, updatedAt: new Date() },
|
||||
])
|
||||
|
||||
const r = await buscar('X')
|
||||
expect(r.map((i) => i.tipo)).toEqual(['lead', 'contacto', 'oportunidad', 'unidad'])
|
||||
})
|
||||
|
||||
it('mezcla los resultados y limita al total indicado', async () => {
|
||||
const leads = Array.from({ length: 10 }, (_, i) => ({
|
||||
leadId: `l${i}`, nombreLead: `L${i}`, telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date(),
|
||||
}))
|
||||
const clientes = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `c${i}`, nombreCompleto: `C${i}`, curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date(),
|
||||
}))
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue(leads)
|
||||
prismaMock.cliente.findMany.mockResolvedValue(clientes)
|
||||
|
||||
const r = await buscar('x', 15)
|
||||
expect(r).toHaveLength(15)
|
||||
// Primeros 10 son leads (prioridad), siguientes 5 son contactos
|
||||
expect(r.filter((i) => i.tipo === 'lead')).toHaveLength(10)
|
||||
expect(r.filter((i) => i.tipo === 'contacto')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('lead: detalle muestra teléfono · email, o origen como fallback', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{ leadId: 'l1', nombreLead: 'Ana', telefono: '4771112233', email: 'ana@x.com', origen: null, board: 'LEADS', updatedAt: new Date() },
|
||||
])
|
||||
const r = await buscar('Ana')
|
||||
expect(r[0]?.detalle).toBe('4771112233 · ana@x.com')
|
||||
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{ leadId: 'l2', nombreLead: 'Bob', telefono: null, email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
|
||||
])
|
||||
const r2 = await buscar('Bob')
|
||||
expect(r2[0]?.detalle).toBe('Facebook')
|
||||
})
|
||||
|
||||
it('contacto: detalle muestra email · celular · proyecto', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'c1', nombreCompleto: 'Luis', curp: '', rfc: '', email: 'luis@x.com', celular: '4772223333', proyecto: 'SENDA', createdAt: new Date() },
|
||||
])
|
||||
const r = await buscar('Luis')
|
||||
expect(r[0]?.detalle).toBe('luis@x.com · 4772223333 · SENDA')
|
||||
})
|
||||
|
||||
it('oportunidad: detalle muestra unidadCodigo · desarrollo, o tipoPropuesta como fallback', async () => {
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||
{ id: 'ct1', clienteNombre: 'Carlos', clienteEmail: null, unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', tipoPropuesta: null, createdAt: new Date() },
|
||||
])
|
||||
const r = await buscar('Carlos')
|
||||
expect(r[0]?.detalle).toBe('U-101 · DIAGONAL')
|
||||
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||
{ id: 'ct2', clienteNombre: 'Diana', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: 'CONTADO', createdAt: new Date() },
|
||||
])
|
||||
const r2 = await buscar('Diana')
|
||||
expect(r2[0]?.detalle).toBe('CONTADO')
|
||||
})
|
||||
|
||||
it('unidad: detalle muestra desarrollo · modelo · status', async () => {
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ id: 'u1', codigo: 'U-202', desarrollo: 'ESTANZA', modelo: 'SOHO', status: 'Disponible', updatedAt: new Date() },
|
||||
])
|
||||
const r = await buscar('U-202')
|
||||
expect(r[0]?.detalle).toBe('ESTANZA · SOHO · Disponible')
|
||||
})
|
||||
|
||||
it('urls de navegación son correctas por tipo', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
|
||||
])
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ id: 'u1', codigo: 'X', desarrollo: null, modelo: null, status: null, updatedAt: new Date() },
|
||||
])
|
||||
|
||||
const r = await buscar('X')
|
||||
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
|
||||
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
|
||||
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
||||
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
||||
})
|
||||
})
|
||||
111
api-gateway/src/services/buscar.service.ts
Normal file
111
api-gateway/src/services/buscar.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export interface BuscadoItem {
|
||||
tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad'
|
||||
id: string
|
||||
nombre: string
|
||||
detalle: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Búsqueda global: LeadCaptura (leads), Cliente (contactos), Cotizacion
|
||||
* (oportunidades con cotización), y Unidad (inventario).
|
||||
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
|
||||
*/
|
||||
export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[]> {
|
||||
const q = termino.trim()
|
||||
if (q === '') return []
|
||||
const pat = `%${q}%`
|
||||
|
||||
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nombreLead: { contains: q } },
|
||||
{ telefono: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cliente.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nombreCompleto: { contains: q } },
|
||||
{ curp: { contains: q } },
|
||||
{ rfc: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
{ celular: { contains: q } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cotizacion.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clienteNombre: { contains: q } },
|
||||
{ clienteEmail: { contains: q } },
|
||||
{ unidadCodigo: { contains: q } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.unidad.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ codigo: { contains: q } },
|
||||
{ desarrollo: { contains: q } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
])
|
||||
|
||||
const items: BuscadoItem[] = [
|
||||
...leads.map((l) => ({
|
||||
tipo: 'lead' as const,
|
||||
id: l.leadId,
|
||||
nombre: l.nombreLead,
|
||||
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
||||
url: `/app/#leads`,
|
||||
})),
|
||||
...clientes.map((c) => ({
|
||||
tipo: 'contacto' as const,
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
||||
url: `/app/#clientes`,
|
||||
})),
|
||||
...cotizaciones.map((ct) => ({
|
||||
tipo: 'oportunidad' as const,
|
||||
id: ct.id,
|
||||
nombre: ct.clienteNombre,
|
||||
detalle: [ct.unidadCodigo, ct.desarrollo].filter(Boolean).join(' · ') || ct.tipoPropuesta,
|
||||
url: `/quote/?cotizacion=${ct.id}`,
|
||||
})),
|
||||
...unidades.map((u) => ({
|
||||
tipo: 'unidad' as const,
|
||||
id: u.id,
|
||||
nombre: u.codigo,
|
||||
detalle: [u.desarrollo, u.modelo, u.status].filter(Boolean).join(' · '),
|
||||
url: `/quote/?unidad=${u.codigo}`,
|
||||
})),
|
||||
]
|
||||
|
||||
// Mezclar y limitar: prioridad por tipo (leads primero, luego contactos,
|
||||
// oportunidades, unidades) y recientes primero (ya ordenados por fecha desc)
|
||||
const porTipo: BuscadoItem['tipo'][] = ['lead', 'contacto', 'oportunidad', 'unidad']
|
||||
items.sort((a, b) => {
|
||||
const ia = porTipo.indexOf(a.tipo)
|
||||
const ib = porTipo.indexOf(b.tipo)
|
||||
if (ia !== ib) return ia - ib
|
||||
return 0
|
||||
})
|
||||
|
||||
return items.slice(0, limite)
|
||||
}
|
||||
54
api-gateway/src/services/nota-apartado.service.test.ts
Normal file
54
api-gateway/src/services/nota-apartado.service.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
notaApartado: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { crearNota, listarNotas } from './nota-apartado.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('crearNota', () => {
|
||||
it('crea una nota con los datos correctos', async () => {
|
||||
const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola', createdAt: new Date() }
|
||||
prismaMock.notaApartado.create.mockResolvedValue(notaFake)
|
||||
|
||||
const resultado = await crearNota('apt-1', 'u1', 'Hola')
|
||||
expect(prismaMock.notaApartado.create).toHaveBeenCalledWith({
|
||||
data: { apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola' },
|
||||
})
|
||||
expect(resultado).toEqual(notaFake)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listarNotas', () => {
|
||||
it('retorna notas ordenadas por createdAt desc', async () => {
|
||||
const notasFake = [
|
||||
{ id: 'nota-2', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'B', createdAt: new Date('2026-09-02') },
|
||||
{ id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date('2026-09-01') },
|
||||
]
|
||||
prismaMock.notaApartado.findMany.mockResolvedValue(notasFake)
|
||||
|
||||
const resultado = await listarNotas('apt-1')
|
||||
expect(prismaMock.notaApartado.findMany).toHaveBeenCalledWith({
|
||||
where: { apartadoId: 'apt-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
expect(resultado).toEqual(notasFake)
|
||||
})
|
||||
|
||||
it('retorna array vacío si no hay notas', async () => {
|
||||
prismaMock.notaApartado.findMany.mockResolvedValue([])
|
||||
const resultado = await listarNotas('apt-inexistente')
|
||||
expect(resultado).toEqual([])
|
||||
})
|
||||
})
|
||||
26
api-gateway/src/services/nota-apartado.service.ts
Normal file
26
api-gateway/src/services/nota-apartado.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export interface NotaApartado {
|
||||
id: string
|
||||
apartadoId: string
|
||||
usuarioId: string
|
||||
contenido: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export async function crearNota(
|
||||
apartadoId: string,
|
||||
usuarioId: string,
|
||||
contenido: string,
|
||||
): Promise<NotaApartado> {
|
||||
return prisma.notaApartado.create({
|
||||
data: { apartadoId, usuarioId, contenido },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listarNotas(apartadoId: string): Promise<NotaApartado[]> {
|
||||
return prisma.notaApartado.findMany({
|
||||
where: { apartadoId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
140
api-gateway/src/services/push.service.test.ts
Normal file
140
api-gateway/src/services/push.service.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
pushSubscription: {
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
// ---------- web-push mockeado ----------
|
||||
const webPushMock = vi.hoisted(() => ({
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('web-push', () => ({ default: webPushMock }))
|
||||
|
||||
// ---------- config mockeado ----------
|
||||
vi.mock('../config/index.js', () => ({
|
||||
config: {
|
||||
push: {
|
||||
vapidPublicKey: 'test-public-key',
|
||||
vapidPrivateKey: 'test-private-key',
|
||||
vapidEmail: 'mailto:test@frasa.com',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
registrarSuscripcion,
|
||||
eliminarSuscripcion,
|
||||
enviarPush,
|
||||
usuarioIdPorMondayUserId,
|
||||
obtenerVapidPublicKey,
|
||||
} from './push.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('push.service', () => {
|
||||
describe('registrarSuscripcion', () => {
|
||||
it('hace upsert con los datos de la suscripción', async () => {
|
||||
prismaMock.pushSubscription.upsert.mockResolvedValue({})
|
||||
await registrarSuscripcion('user-1', {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
p256dh: 'key123',
|
||||
auth: 'auth456',
|
||||
})
|
||||
expect(prismaMock.pushSubscription.upsert).toHaveBeenCalledWith({
|
||||
where: { usuarioId: 'user-1' },
|
||||
update: {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
p256dh: 'key123',
|
||||
auth: 'auth456',
|
||||
},
|
||||
create: {
|
||||
usuarioId: 'user-1',
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
p256dh: 'key123',
|
||||
auth: 'auth456',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('eliminarSuscripcion', () => {
|
||||
it('elimina todas las suscripciones del usuario', async () => {
|
||||
prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 })
|
||||
await eliminarSuscripcion('user-1')
|
||||
expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('enviarPush', () => {
|
||||
it('envía push si el usuario tiene suscripción', async () => {
|
||||
prismaMock.pushSubscription.findUnique.mockResolvedValue({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
p256dh: 'key123',
|
||||
auth: 'auth456',
|
||||
})
|
||||
webPushMock.sendNotification.mockResolvedValue({ statusCode: 201 })
|
||||
|
||||
const result = await enviarPush('user-1', 'Test', 'Cuerpo del mensaje')
|
||||
expect(result).toBe(true)
|
||||
expect(webPushMock.sendNotification).toHaveBeenCalledWith(
|
||||
{ endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys: { p256dh: 'key123', auth: 'auth456' } },
|
||||
expect.stringContaining('"titulo":"Test"'),
|
||||
)
|
||||
})
|
||||
|
||||
it('devuelve false si el usuario no tiene suscripción', async () => {
|
||||
prismaMock.pushSubscription.findUnique.mockResolvedValue(null)
|
||||
const result = await enviarPush('user-1', 'Test', 'Cuerpo')
|
||||
expect(result).toBe(false)
|
||||
expect(webPushMock.sendNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('elimina suscripción inválida (404) y devuelve false', async () => {
|
||||
prismaMock.pushSubscription.findUnique.mockResolvedValue({
|
||||
endpoint: 'https://expired',
|
||||
p256dh: 'key',
|
||||
auth: 'auth',
|
||||
})
|
||||
webPushMock.sendNotification.mockRejectedValue({ statusCode: 404 })
|
||||
prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
const result = await enviarPush('user-1', 'Test', 'Cuerpo')
|
||||
expect(result).toBe(false)
|
||||
expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('usuarioIdPorMondayUserId', () => {
|
||||
it('devuelve el usuarioId si encuentra el usuario', async () => {
|
||||
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'local-user-1' })
|
||||
const result = await usuarioIdPorMondayUserId(12345)
|
||||
expect(result).toBe('local-user-1')
|
||||
})
|
||||
|
||||
it('devuelve null si no encuentra el usuario', async () => {
|
||||
prismaMock.usuario.findFirst.mockResolvedValue(null)
|
||||
const result = await usuarioIdPorMondayUserId(99999)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('obtenerVapidPublicKey', () => {
|
||||
it('devuelve la VAPID public key del config', () => {
|
||||
expect(obtenerVapidPublicKey()).toBe('test-public-key')
|
||||
})
|
||||
})
|
||||
})
|
||||
87
api-gateway/src/services/push.service.ts
Normal file
87
api-gateway/src/services/push.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import webPush from 'web-push'
|
||||
import { config } from '../config/index.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
// Configurar VAPID solo si las claves están presentes
|
||||
if (config.push.vapidPublicKey && config.push.vapidPrivateKey) {
|
||||
webPush.setVapidDetails(
|
||||
config.push.vapidEmail,
|
||||
config.push.vapidPublicKey,
|
||||
config.push.vapidPrivateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export interface PushSubscriptionInput {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
/** Registra o actualiza la suscripción push de un usuario */
|
||||
export async function registrarSuscripcion(usuarioId: string, subscription: PushSubscriptionInput): Promise<void> {
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { usuarioId },
|
||||
update: {
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
create: {
|
||||
usuarioId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Elimina la suscripción push de un usuario */
|
||||
export async function eliminarSuscripcion(usuarioId: string): Promise<void> {
|
||||
await prisma.pushSubscription.deleteMany({ where: { usuarioId } })
|
||||
}
|
||||
|
||||
/** Devuelve la VAPID public key para el frontend */
|
||||
export function obtenerVapidPublicKey(): string {
|
||||
return config.push.vapidPublicKey
|
||||
}
|
||||
|
||||
/** Busca el usuarioId local a partir de un mondayUserId */
|
||||
export async function usuarioIdPorMondayUserId(mondayUserId: number): Promise<string | null> {
|
||||
const usuario = await prisma.usuario.findFirst({
|
||||
where: { mondayUserId },
|
||||
select: { id: true },
|
||||
})
|
||||
return usuario?.id ?? null
|
||||
}
|
||||
|
||||
/** Envía una notificación push a un usuario. Silencioso si no tiene suscripción o si web-push no está configurado. */
|
||||
export async function enviarPush(
|
||||
usuarioId: string,
|
||||
titulo: string,
|
||||
cuerpo: string,
|
||||
url?: string,
|
||||
): Promise<boolean> {
|
||||
if (!config.push.vapidPublicKey || !config.push.vapidPrivateKey) return false
|
||||
|
||||
const sub = await prisma.pushSubscription.findUnique({ where: { usuarioId } })
|
||||
if (!sub) return false
|
||||
|
||||
const payload = JSON.stringify({ titulo, cuerpo, url: url ?? '/' })
|
||||
|
||||
try {
|
||||
await webPush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
// Si la suscripción ya no es válida (404 o 410), eliminarla
|
||||
const status = (err as { statusCode?: number }).statusCode
|
||||
if (status === 404 || status === 410) {
|
||||
await prisma.pushSubscription.deleteMany({ where: { usuarioId } })
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[push] No se pudo enviar a ${usuarioId}:`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from '.
|
||||
import { LocalCanal } from './local-canal.js'
|
||||
import { mondayService, type MondayService } from './monday.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
import { enviarPush } from './push.service.js'
|
||||
|
||||
/**
|
||||
* Motor de recordatorios de pago de la cobranza.
|
||||
@@ -272,6 +273,10 @@ export class RecordatoriosCobranzaService {
|
||||
if (canal) await canal.enviar(destinatario, c.mensaje, hoyISO(ahora))
|
||||
await this.store.registrarLog(enviado)
|
||||
resultado.enviados.push(enviado)
|
||||
// Push notification al vendedor
|
||||
if (c.vendedorId) {
|
||||
void enviarPush(c.vendedorId, 'Recordatorio de pago', c.mensaje, '/hoy/')
|
||||
}
|
||||
} 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) })
|
||||
|
||||
147
api-gateway/src/services/reporte-mi-tablero.service.test.ts
Normal file
147
api-gateway/src/services/reporte-mi-tablero.service.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { construirMiTablero } from './reporte-mi-tablero.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
function captura(
|
||||
overrides: Partial<{
|
||||
leadId: string
|
||||
board: string
|
||||
etapa: string
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
leadId: overrides.leadId ?? `cap-${Math.random().toString(36).slice(2, 8)}`,
|
||||
board: overrides.board ?? 'LEADS',
|
||||
etapa: overrides.etapa ?? 'NUEVO',
|
||||
origen: overrides.origen ?? 'Facebook',
|
||||
desarrollo: overrides.desarrollo ?? 'DIAGONAL',
|
||||
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
|
||||
}
|
||||
}
|
||||
|
||||
function cotizacion(
|
||||
overrides: Partial<{ precioFinal: number; vendedorId: string | null; createdAt: Date }> = {},
|
||||
) {
|
||||
return {
|
||||
precioFinal: overrides.precioFinal ?? 850000,
|
||||
vendedorId: overrides.vendedorId ?? 'u-1',
|
||||
createdAt: overrides.createdAt ?? new Date('2026-09-01T12:00:00'),
|
||||
}
|
||||
}
|
||||
|
||||
function actividad(
|
||||
overrides: Partial<{ usuarioId: string; estado: string; fechaVencimiento: Date | null }> = {},
|
||||
) {
|
||||
return {
|
||||
usuarioId: overrides.usuarioId ?? 'u-1',
|
||||
estado: overrides.estado ?? 'ABIERTA',
|
||||
fechaVencimiento: overrides.fechaVencimiento ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const AHORA = new Date('2026-09-07T12:00:00')
|
||||
|
||||
// ---------- construirMiTablero (puro) ----------
|
||||
|
||||
describe('construirMiTablero', () => {
|
||||
it('genera 30 entradas en leadsPorDia con ceros para días sin leads', () => {
|
||||
const tablero = construirMiTablero([], [], [], AHORA)
|
||||
expect(tablero.leadsPorDia.length).toBe(30)
|
||||
// Todos los días deben tener cantidad 0
|
||||
expect(tablero.leadsPorDia.every((d) => d.cantidad === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('cuenta leads por día correctamente', () => {
|
||||
const hace2Dias = new Date(AHORA.getTime() - 2 * 24 * 60 * 60 * 1000)
|
||||
const capturas = [
|
||||
captura({ createdAt: hace2Dias }),
|
||||
captura({ createdAt: hace2Dias }),
|
||||
captura({ createdAt: hace2Dias }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
const entrada = tablero.leadsPorDia.find((d) => d.cantidad > 0)
|
||||
expect(entrada).toBeDefined()
|
||||
expect(entrada!.cantidad).toBe(3)
|
||||
})
|
||||
|
||||
it('cuenta conversión correctamente', () => {
|
||||
const capturas = [
|
||||
captura({ board: 'LEADS' }),
|
||||
captura({ board: 'LEADS' }),
|
||||
captura({ board: 'OPORTUNIDADES' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
expect(tablero.conversion.leads).toBe(2)
|
||||
expect(tablero.conversion.oportunidades).toBe(1)
|
||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
||||
expect(tablero.conversion.vendidos).toBe(1)
|
||||
})
|
||||
|
||||
it('calcula ventas del mes correctamente', () => {
|
||||
const cots = [
|
||||
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
|
||||
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
|
||||
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
|
||||
]
|
||||
const tablero = construirMiTablero([], cots, [], AHORA)
|
||||
expect(tablero.ventasMes.unidades).toBe(2)
|
||||
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000
|
||||
})
|
||||
|
||||
it('agrupa por origen con conteo de convertidos', () => {
|
||||
const capturas = [
|
||||
captura({ board: 'LEADS', origen: 'Facebook' }),
|
||||
captura({ board: 'LEADS', origen: 'Facebook' }),
|
||||
captura({ board: 'OPORTUNIDADES', origen: 'Facebook' }),
|
||||
captura({ board: 'LEADS', origen: 'Referido' }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
const fb = tablero.porOrigen.find((o) => o.origen === 'Facebook')
|
||||
expect(fb).toBeDefined()
|
||||
expect(fb!.total).toBe(3)
|
||||
expect(fb!.convertidos).toBe(1)
|
||||
const ref = tablero.porOrigen.find((o) => o.origen === 'Referido')
|
||||
expect(ref).toBeDefined()
|
||||
expect(ref!.total).toBe(1)
|
||||
expect(ref!.convertidos).toBe(0)
|
||||
})
|
||||
|
||||
it('agrupa por desarrollo solo con vendidos', () => {
|
||||
const capturas = [
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
|
||||
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
|
||||
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
expect(tablero.porDesarrollo).toEqual([
|
||||
{ desarrollo: 'DIAGONAL', ventas: 2 },
|
||||
{ desarrollo: 'ATELIER', ventas: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('cuenta actividades hoy y pendientes', () => {
|
||||
const actividades = [
|
||||
actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-07T10:00:00') }),
|
||||
actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-08T10:00:00') }),
|
||||
actividad({ estado: 'ABIERTA' }),
|
||||
actividad({ estado: 'CERRADA' }),
|
||||
]
|
||||
const tablero = construirMiTablero([], [], actividades, AHORA)
|
||||
expect(tablero.actividadesHoy).toBe(2) // 1 vencida + 1 sin fecha (hoy)
|
||||
expect(tablero.actividadesPendientes).toBe(3) // 3 ABIERTA
|
||||
})
|
||||
|
||||
it('normaliza orígenes vacíos a (sin origen)', () => {
|
||||
const capturas = [captura({ board: 'LEADS', origen: '' })]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
expect(tablero.porOrigen.length).toBe(1)
|
||||
expect(tablero.porOrigen[0].origen).toBe('(sin origen)')
|
||||
})
|
||||
})
|
||||
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Interfaces ----------
|
||||
|
||||
export interface MiTablero {
|
||||
generadoEn: string
|
||||
leadsPorDia: { fecha: string; cantidad: number }[]
|
||||
conversion: {
|
||||
leads: number
|
||||
oportunidades: number
|
||||
apartados: number
|
||||
vendidos: number
|
||||
}
|
||||
ventasMes: {
|
||||
monto: number
|
||||
unidades: number
|
||||
}
|
||||
porOrigen: {
|
||||
origen: string
|
||||
total: number
|
||||
convertidos: number
|
||||
}[]
|
||||
porDesarrollo: {
|
||||
desarrollo: string
|
||||
ventas: number
|
||||
}[]
|
||||
actividadesHoy: number
|
||||
actividadesPendientes: number
|
||||
}
|
||||
|
||||
export interface MetricasUsoUsuario {
|
||||
usuarioId: string
|
||||
nombre: string
|
||||
rol: string
|
||||
leadsHoy: number
|
||||
leadsSemana: number
|
||||
leadsMes: number
|
||||
tasaDescarte: number
|
||||
actividadesCompletadas: number
|
||||
actividadesPendientes: number
|
||||
}
|
||||
|
||||
export interface MetricasUso {
|
||||
generadoEn: string
|
||||
usuarios: MetricasUsoUsuario[]
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
/** Inicio del día local de `ahora` (medianoche) */
|
||||
function inicioDelDia(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
/** Fecha local 'YYYY-MM-DD' */
|
||||
function formatoFecha(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${dd}`
|
||||
}
|
||||
|
||||
// ---------- Servicio: Mi Tablero ----------
|
||||
|
||||
/**
|
||||
* Construye el tablero personal del vendedor: leads por día (últimos 30 días),
|
||||
* tasa de conversión, ventas del mes, rendimiento por origen, ranking de
|
||||
* desarrollos y conteo de actividades.
|
||||
*
|
||||
* Función pura: recibe los datos crudos desde Prisma; testeable sin BD.
|
||||
*/
|
||||
export function construirMiTablero(
|
||||
capturas: {
|
||||
leadId: string
|
||||
board: string
|
||||
etapa: string
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
}[],
|
||||
cotizaciones: {
|
||||
precioFinal: number
|
||||
vendedorId: string | null
|
||||
createdAt: Date
|
||||
}[],
|
||||
actividades: {
|
||||
usuarioId: string
|
||||
estado: string
|
||||
fechaVencimiento: Date | null
|
||||
}[],
|
||||
ahora: Date,
|
||||
): MiTablero {
|
||||
const hoy = inicioDelDia(ahora)
|
||||
const hace30Dias = new Date(hoy.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// -- Leads por día (últimos 30 días) --
|
||||
const leadsPorDiaMap = new Map<string, number>()
|
||||
// Inicializar todos los días para que la gráfica no tenga huecos
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(hoy.getTime() - (29 - i) * 24 * 60 * 60 * 1000)
|
||||
leadsPorDiaMap.set(formatoFecha(d), 0)
|
||||
}
|
||||
for (const c of capturas) {
|
||||
const fecha = c.createdAt
|
||||
if (fecha >= hace30Dias) {
|
||||
const clave = formatoFecha(fecha)
|
||||
if (leadsPorDiaMap.has(clave)) {
|
||||
leadsPorDiaMap.set(clave, (leadsPorDiaMap.get(clave) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
const leadsPorDia = [...leadsPorDiaMap.entries()]
|
||||
.map(([fecha, cantidad]) => ({ fecha, cantidad }))
|
||||
.sort((a, b) => a.fecha.localeCompare(b.fecha))
|
||||
|
||||
// -- Conversión (funnel) --
|
||||
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
||||
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
|
||||
const apartados = capturas.filter(
|
||||
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
|
||||
).length
|
||||
const vendidos = capturas.filter(
|
||||
(c) =>
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||
).length
|
||||
|
||||
// -- Ventas del mes --
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
const cotizacionesMes = cotizaciones.filter(
|
||||
(ct) => ct.createdAt >= inicioMes,
|
||||
)
|
||||
const ventasMes = {
|
||||
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
|
||||
unidades: cotizacionesMes.length,
|
||||
}
|
||||
|
||||
// -- Por origen --
|
||||
const origenMap = new Map<string, { total: number; convertidos: number }>()
|
||||
for (const c of capturas) {
|
||||
const origen = c.origen?.trim() || '(sin origen)'
|
||||
const entrada = origenMap.get(origen) ?? { total: 0, convertidos: 0 }
|
||||
entrada.total++
|
||||
if (c.board === 'OPORTUNIDADES' || c.board === 'APARTADO') {
|
||||
entrada.convertidos++
|
||||
}
|
||||
origenMap.set(origen, entrada)
|
||||
}
|
||||
const porOrigen = [...origenMap.entries()]
|
||||
.map(([origen, v]) => ({ origen, ...v }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
|
||||
// -- Por desarrollo (solo apartados vendidos) --
|
||||
const desarrolloMap = new Map<string, number>()
|
||||
for (const c of capturas) {
|
||||
if (
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
|
||||
c.desarrollo
|
||||
) {
|
||||
const dev = c.desarrollo.trim()
|
||||
if (dev !== '') {
|
||||
desarrolloMap.set(dev, (desarrolloMap.get(dev) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
const porDesarrollo = [...desarrolloMap.entries()]
|
||||
.map(([desarrollo, ventas]) => ({ desarrollo, ventas }))
|
||||
.sort((a, b) => b.ventas - a.ventas)
|
||||
|
||||
// -- Actividades --
|
||||
const manana = new Date(hoy.getTime() + 24 * 60 * 60 * 1000)
|
||||
const actividadesHoy = actividades.filter(
|
||||
(a) =>
|
||||
a.estado.toUpperCase() === 'ABIERTA' &&
|
||||
(!a.fechaVencimiento || a.fechaVencimiento < manana),
|
||||
).length
|
||||
const actividadesPendientes = actividades.filter(
|
||||
(a) => a.estado.toUpperCase() === 'ABIERTA',
|
||||
).length
|
||||
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
leadsPorDia,
|
||||
conversion: { leads, oportunidades, apartados, vendidos },
|
||||
ventasMes,
|
||||
porOrigen,
|
||||
porDesarrollo,
|
||||
actividadesHoy,
|
||||
actividadesPendientes,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Servicio con Prisma ----------
|
||||
|
||||
/**
|
||||
* Obtiene el tablero personal de un vendedor desde la base de datos.
|
||||
*/
|
||||
export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
||||
const ahora = new Date()
|
||||
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { usuarioId },
|
||||
select: {
|
||||
board: true,
|
||||
etapa: true,
|
||||
origen: true,
|
||||
desarrollo: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const cotizaciones = await prisma.cotizacion.findMany({
|
||||
where: { vendedorId: usuarioId },
|
||||
select: {
|
||||
precioFinal: true,
|
||||
vendedorId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const actividades = await prisma.actividadLocal.findMany({
|
||||
where: { usuarioId },
|
||||
select: {
|
||||
usuarioId: true,
|
||||
estado: true,
|
||||
fechaVencimiento: true,
|
||||
},
|
||||
})
|
||||
|
||||
return construirMiTablero(capturas, cotizaciones, actividades, ahora)
|
||||
}
|
||||
|
||||
// ---------- Servicio: Métricas de uso (admin/coordinador) ----------
|
||||
|
||||
/**
|
||||
* Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de
|
||||
* descarte, actividades completadas vs pendientes. Visible solo para admin y
|
||||
* coordinador.
|
||||
*/
|
||||
export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUso> {
|
||||
const ahora = new Date()
|
||||
const hoy = inicioDelDia(ahora)
|
||||
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
|
||||
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
|
||||
const whereUsuario = usuarioId ? { usuarioId } : undefined
|
||||
|
||||
const [capturas, actividades, usuarios] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: whereUsuario,
|
||||
select: {
|
||||
usuarioId: true,
|
||||
etapa: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.actividadLocal.findMany({
|
||||
where: whereUsuario,
|
||||
select: {
|
||||
usuarioId: true,
|
||||
estado: true,
|
||||
},
|
||||
}),
|
||||
prisma.usuario.findMany({
|
||||
where: whereUsuario ? { id: usuarioId } : { activo: true },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rol: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const metricasPorUsuario: MetricasUsoUsuario[] = usuarios.map((u) => {
|
||||
const misCapturas = capturas.filter((c) => c.usuarioId === u.id)
|
||||
const leadsHoy = misCapturas.filter((c) => c.createdAt >= hoy).length
|
||||
const leadsSemana = misCapturas.filter((c) => c.createdAt >= hace7dias).length
|
||||
const leadsMes = misCapturas.filter((c) => c.createdAt >= inicioMes).length
|
||||
const descartados = misCapturas.filter((c) =>
|
||||
c.etapa.toUpperCase() === 'DESCARTADO',
|
||||
).length
|
||||
const tasaDescarte = leadsMes > 0 ? Math.round((descartados / leadsMes) * 100) / 100 : 0
|
||||
const misActividades = actividades.filter((a) => a.usuarioId === u.id)
|
||||
const actividadesCompletadas = misActividades.filter(
|
||||
(a) => a.estado.toUpperCase() === 'CERRADA',
|
||||
).length
|
||||
const actividadesPendientes = misActividades.filter(
|
||||
(a) => a.estado.toUpperCase() === 'ABIERTA',
|
||||
).length
|
||||
|
||||
return {
|
||||
usuarioId: u.id,
|
||||
nombre: u.nombre,
|
||||
rol: u.rol,
|
||||
leadsHoy,
|
||||
leadsSemana,
|
||||
leadsMes,
|
||||
tasaDescarte,
|
||||
actividadesCompletadas,
|
||||
actividadesPendientes,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
usuarios: metricasPorUsuario,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user