feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday
Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario Mi día: modal español real, cita desde modal, score falso eliminado Cotizador: barra sticky, persistencia local con folio Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones 69 archivos, 7 apps construidas
This commit is contained in:
68
api-gateway/src/config/vocabulario.ts
Normal file
68
api-gateway/src/config/vocabulario.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* VOCABULARIO canónico FRASA del backend: fases, estados, etapas, motivos,
|
||||
* subcategorías, orígenes y desarrollos en un solo catálogo.
|
||||
*
|
||||
* Espejo de apps/mi-dia/src/lib/mi-dia.ts (mantener sincronizados). Los
|
||||
* controllers siguen exportando sus constantes de validación zod derivadas de
|
||||
* este mismo vocabulario:
|
||||
* - leads.controller.ts → ESTADOS_LEAD_PERMITIDOS (FASES_LEAD sin NUEVO/DESCARTADO)
|
||||
* - oportunidades.controller.ts → ETAPAS_OPORTUNIDAD_PERMITIDAS (= FASES_OPORTUNIDAD),
|
||||
* MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA
|
||||
* - apartados.controller.ts → FASES_APARTADO_PERMITIDAS (= FASES_APARTADO)
|
||||
*/
|
||||
export const VOCABULARIO = {
|
||||
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
|
||||
// API + NUEVO (captura) y DESCARTADO (terminal)
|
||||
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
|
||||
// Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
|
||||
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la
|
||||
// columna status 'Estado'); incluye las fases de escrituración
|
||||
FASES_APARTADO: [
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente',
|
||||
'Cancelacion Apartado',
|
||||
'Venta',
|
||||
'Seguimiento',
|
||||
'Integracion',
|
||||
'Llamada Escrituras',
|
||||
'X Escriturar',
|
||||
'Banco/Infonavit',
|
||||
'X Autorizar',
|
||||
'Autorizado',
|
||||
'Escriturado',
|
||||
'Titulado',
|
||||
'Cancelo',
|
||||
],
|
||||
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
|
||||
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
|
||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
|
||||
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
|
||||
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
|
||||
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
|
||||
// conservan las faltas de ortografía originales del tablero del cliente)
|
||||
SUBCATEGORIAS: {
|
||||
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
|
||||
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
|
||||
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
|
||||
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
|
||||
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
|
||||
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
|
||||
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
|
||||
},
|
||||
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
|
||||
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
|
||||
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
|
||||
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
|
||||
} as const
|
||||
|
||||
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
||||
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
||||
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
|
||||
export type FaseEscrituracion = (typeof VOCABULARIO.FASES_ESCRITURACION)[number]
|
||||
export type MotivoNoCompra = (typeof VOCABULARIO.MOTIVOS_NO_COMPRA)[number]
|
||||
@@ -12,7 +12,13 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
|
||||
const pagina = req.query.pagina ? Number(req.query.pagina) : undefined
|
||||
const limite = req.query.limite ? Number(req.query.limite) : undefined
|
||||
|
||||
const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite })
|
||||
const resultado = await clienteMaestroService.listar({
|
||||
busqueda,
|
||||
desarrollo,
|
||||
estatus,
|
||||
...(pagina !== undefined ? { pagina } : {}),
|
||||
...(limite !== undefined ? { limite } : {}),
|
||||
})
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => {
|
||||
|
||||
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
|
||||
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' })
|
||||
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
|
||||
expect(detalle['propiedad']).toBeNull()
|
||||
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
|
||||
expect(Array.isArray(detalle['historial'])).toBe(true)
|
||||
|
||||
@@ -185,6 +185,7 @@ type MondayLeads = Pick<
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'buscarOportunidadPorTelefono'
|
||||
| 'getLeads'
|
||||
| 'getOportunidades'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
| 'columnaIdPorTitulo'
|
||||
|
||||
@@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
|
||||
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
|
||||
const usuarioFake = {
|
||||
id: 'u-1',
|
||||
@@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
|
||||
saludMonday.restablecer()
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.actividadLocal.findMany.mockResolvedValue([])
|
||||
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
||||
@@ -96,9 +89,8 @@ beforeEach(() => {
|
||||
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
|
||||
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
|
||||
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
|
||||
@@ -109,15 +101,16 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
|
||||
expect(mondayMock.getLeads).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getApartados).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect((resumen["misLeads"] as unknown[]).length).toBe(1) // solo leads, no oportunidades
|
||||
expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
|
||||
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
|
||||
expect(resumen['misActividadesHoy']).toEqual([])
|
||||
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
|
||||
})
|
||||
|
||||
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
|
||||
])
|
||||
@@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1'])
|
||||
expect((resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]).map((l) => l.id)).toEqual(['loc_l1'])
|
||||
expect((resumen['misLeads'] as { pendienteSync?: boolean }[])[0]?.pendienteSync).toBe(true)
|
||||
})
|
||||
|
||||
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => {
|
||||
mondayMock.getLeads.mockResolvedValue([
|
||||
{
|
||||
id: '9001',
|
||||
nombre: 'Lead Monday',
|
||||
createdAt: '2026-09-01T10:00:00.000Z',
|
||||
updatedAt: '2026-09-02T10:00:00.000Z',
|
||||
estado: 'Nuevo',
|
||||
origen: 'Web',
|
||||
telefono: '',
|
||||
email: '',
|
||||
vendedorIds: [123],
|
||||
},
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
|
||||
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
|
||||
expect(leads[0]?.pendienteSync).toBe(true)
|
||||
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
|
||||
// Y la captura quedó marcada en Mis capturas
|
||||
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
|
||||
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
|
||||
})
|
||||
|
||||
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
|
||||
it('sin capturas: listas vacías y sin flags de sync', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
@@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||
})
|
||||
|
||||
it('un error no-Monday se propaga (no se traga nada)', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
|
||||
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
|
||||
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('un error de BD se propaga (no se traga nada)', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockRejectedValue(new AppError(500, 'bug'))
|
||||
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
|
||||
/**
|
||||
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
|
||||
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
|
||||
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true.
|
||||
* SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal):
|
||||
* Monday ya no participa en este endpoint. El "mío" se resuelve por el
|
||||
* usuarioId de cada captura; admin/dirección ven todas.
|
||||
*/
|
||||
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
|
||||
@@ -22,27 +19,6 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
|
||||
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||
|
||||
// Nombres de los usuarios ya mapeados a Monday (para la sección equipo)
|
||||
const mapeados = await prisma.usuario.findMany({
|
||||
where: { mondayUserId: { not: null }, activo: true },
|
||||
select: { nombre: true, mondayUserId: true },
|
||||
})
|
||||
const nombresPorMondayId = new Map<number, string>(
|
||||
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
||||
)
|
||||
|
||||
// TODOS los usuarios del workspace de Monday (el "último cambio" de un lead
|
||||
// puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado
|
||||
// gana el nombre del ecosistema. Si Monday falla, el resumen sale igual.
|
||||
try {
|
||||
for (const u of await mondayService.getUsuariosWorkspace()) {
|
||||
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
|
||||
}
|
||||
|
||||
// Capturas de afluencia: admin ve todas, otros solo las suyas
|
||||
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
@@ -50,92 +26,40 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping
|
||||
// LeadOportunidad: conversión con "Agendo" y afluencias históricas)
|
||||
const mappings = await prisma.leadOportunidad.findMany({
|
||||
where: { leadId: { in: capturas.map((c) => c.leadId) } },
|
||||
})
|
||||
const capturadosIds = new Set([
|
||||
...capturas.map((c) => c.leadId),
|
||||
...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null),
|
||||
...mappings.map((m) => m.oportunidadId),
|
||||
])
|
||||
|
||||
// Altas de clientes: admin ve todas, otros solo las suyas
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: esAdmin ? {} : { capturadoPorId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Actividades locales del usuario
|
||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
||||
where: { usuarioId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
||||
id: a.id,
|
||||
titulo: a.titulo,
|
||||
descripcion: a.descripcion,
|
||||
usuarioId: a.usuarioId,
|
||||
leadCapturaId: a.leadCapturaId,
|
||||
fechaVencimiento: a.fechaVencimiento,
|
||||
fechaCierre: a.fechaCierre,
|
||||
estado: a.estado,
|
||||
tipo: a.tipo,
|
||||
}))
|
||||
|
||||
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
||||
let usuarios: UsuarioLocal[] = []
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
}
|
||||
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
||||
|
||||
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
// Actividades locales del usuario
|
||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
||||
where: { usuarioId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
||||
id: a.id,
|
||||
titulo: a.titulo,
|
||||
descripcion: a.descripcion,
|
||||
usuarioId: a.usuarioId,
|
||||
leadCapturaId: a.leadCapturaId,
|
||||
fechaVencimiento: a.fechaVencimiento,
|
||||
fechaCierre: a.fechaCierre,
|
||||
estado: a.estado,
|
||||
tipo: a.tipo,
|
||||
}))
|
||||
|
||||
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
||||
let usuarios: UsuarioLocal[] = []
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
}
|
||||
|
||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resumen = await resumenMiDia(
|
||||
mondayService,
|
||||
usuarioMiDia,
|
||||
nombresPorMondayId,
|
||||
capturas,
|
||||
clientes,
|
||||
new Date(),
|
||||
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
|
||||
capturadosIds,
|
||||
)
|
||||
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
|
||||
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
|
||||
} catch (err) {
|
||||
// Monday se cayó a media lectura: mismo resumen, pero todo local
|
||||
if (!esFalloMonday(err)) throw err
|
||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
||||
where: { usuarioId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
||||
id: a.id, titulo: a.titulo, descripcion: a.descripcion,
|
||||
usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId,
|
||||
fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre,
|
||||
estado: a.estado, tipo: a.tipo,
|
||||
}))
|
||||
let usuarios: UsuarioLocal[] = []
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
}
|
||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
||||
}
|
||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
|
||||
fechaPago: new Date(body.fechaPago),
|
||||
monto: aplicacion.monto,
|
||||
referencia: aplicacion.referencia,
|
||||
// Quien capturó el abono: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
|
||||
capturadoPorId: req.usuarioToken?.sub ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.pago.update({
|
||||
|
||||
@@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) {
|
||||
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 } = {
|
||||
const opts: {
|
||||
completados?: boolean
|
||||
desde?: string | null
|
||||
hasta?: string | null
|
||||
clienteClave?: string | null
|
||||
} = {
|
||||
desde: (req.query['desde'] as string) ?? null,
|
||||
hasta: (req.query['hasta'] as string) ?? null,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ 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)
|
||||
|
||||
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
|
||||
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await reportesService.resumen())
|
||||
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
|
||||
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */
|
||||
export async function resumenReportes(req: Request, res: Response): Promise<void> {
|
||||
const dias = Number(req.query['dias'])
|
||||
res.json(await reportesService.resumen(Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined))
|
||||
}
|
||||
|
||||
/** GET /api/reportes/scoring — scores de todos los leads activos */
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: 13676,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||
acciones: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('login', () => {
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: null,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||
acciones: [],
|
||||
})
|
||||
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
||||
@@ -103,7 +103,7 @@ describe('me', () => {
|
||||
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
|
||||
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
|
||||
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat'])
|
||||
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'])
|
||||
})
|
||||
|
||||
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
||||
|
||||
@@ -602,7 +602,7 @@ export class AutomatizacionesService {
|
||||
pendienteSync: false,
|
||||
},
|
||||
})
|
||||
} catch (e) { console.error('[APARTADO-ERR]', e.message?.slice(0,150)) }
|
||||
} catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) }
|
||||
} catch (err) {
|
||||
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
|
||||
},
|
||||
)
|
||||
expect(d).not.toBeNull()
|
||||
expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0')
|
||||
// El contacto del detalle se devuelve con campos nombrados del modelo local
|
||||
expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0')
|
||||
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
|
||||
expect(d!.propiedad).toMatchObject({
|
||||
unidadCodigo: 'SENDA-409-1-2',
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface EventoHistorialContacto {
|
||||
}
|
||||
|
||||
export interface DetalleContacto {
|
||||
contacto: Record<string, string> // columnas del board por título ('nombre' incluido)
|
||||
contacto: Record<string, string> // campos nombrados del modelo local (nombre, telefono, email, …)
|
||||
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
|
||||
propiedad: {
|
||||
id: string
|
||||
@@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string {
|
||||
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeo título de columna del board Contactos (Monday) → campo nombrado del
|
||||
* modelo local. Los títulos sin entrada se conservan tal cual.
|
||||
*/
|
||||
const CAMPOS_CONTACTO: Record<string, string> = {
|
||||
nombre: 'nombre',
|
||||
'Teléfono': 'telefono',
|
||||
Email: 'email',
|
||||
Estado: 'estado',
|
||||
CURP: 'curp',
|
||||
RFC: 'rfc',
|
||||
'Estado Civil': 'estadoCivil',
|
||||
'Fecha Nacimiento': 'fechaNacimiento',
|
||||
'Actividad Economica': 'actividadEconomica',
|
||||
'Dirección': 'direccion',
|
||||
}
|
||||
|
||||
/** Normaliza un record por título de columna a campos nombrados del modelo local */
|
||||
export function normalizarCamposContacto(contacto: Record<string, string>): Record<string, string> {
|
||||
const normalizado: Record<string, string> = {}
|
||||
for (const [titulo, valor] of Object.entries(contacto)) {
|
||||
normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor
|
||||
}
|
||||
return normalizado
|
||||
}
|
||||
|
||||
type MondayDetalle = Pick<
|
||||
MondayService,
|
||||
| 'getContactoDetalle'
|
||||
@@ -169,7 +195,7 @@ export async function detalleContacto(
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
return {
|
||||
contacto,
|
||||
contacto: normalizarCamposContacto(contacto),
|
||||
propiedad,
|
||||
relaciones: {
|
||||
leads: relLeads.map((l) => ({
|
||||
|
||||
@@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => {
|
||||
|
||||
expect(detalle.contacto).toMatchObject({
|
||||
nombre: 'JUAN PÉREZ',
|
||||
Teléfono: '4771234567',
|
||||
Email: 'juan@correo.mx',
|
||||
CURP: 'CURP123',
|
||||
RFC: 'RFC123',
|
||||
telefono: '4771234567',
|
||||
email: 'juan@correo.mx',
|
||||
estado: 'Cliente',
|
||||
curp: 'CURP123',
|
||||
rfc: 'RFC123',
|
||||
})
|
||||
expect(detalle.propiedad).toMatchObject({
|
||||
id: 'loc_a1',
|
||||
@@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => {
|
||||
const detalle = await detalleContactoLocal('loc_1')
|
||||
expect(detalle).not.toBeNull()
|
||||
if (detalle === null) return
|
||||
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' })
|
||||
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' })
|
||||
expect(detalle.propiedad).toBeNull()
|
||||
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
cliente !== null
|
||||
? {
|
||||
nombre: cliente.nombreCompleto,
|
||||
Teléfono: cliente.celular ?? '',
|
||||
Email: cliente.email ?? '',
|
||||
CURP: cliente.curp,
|
||||
RFC: cliente.rfc,
|
||||
Estado: 'Cliente',
|
||||
telefono: cliente.celular ?? '',
|
||||
email: cliente.email ?? '',
|
||||
estado: 'Cliente',
|
||||
curp: cliente.curp,
|
||||
rfc: cliente.rfc,
|
||||
}
|
||||
: {
|
||||
nombre: capturaBase!.nombreLead,
|
||||
Teléfono: capturaBase!.telefono ?? '',
|
||||
Email: capturaBase!.email ?? '',
|
||||
Estado: capturaBase!.etapa,
|
||||
telefono: capturaBase!.telefono ?? '',
|
||||
email: capturaBase!.email ?? '',
|
||||
estado: capturaBase!.etapa,
|
||||
}
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
|
||||
@@ -218,7 +218,14 @@ export async function resumenMiDia(
|
||||
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
||||
nombresPorMondayId: Map<number, string>,
|
||||
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
||||
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
||||
capturas: {
|
||||
leadId: string
|
||||
nombreLead: string
|
||||
telefono: string | null
|
||||
createdAt: Date
|
||||
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
|
||||
oportunidadId?: string | null
|
||||
}[] = [],
|
||||
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
||||
clientes: {
|
||||
id: string
|
||||
@@ -332,7 +339,10 @@ export async function resumenMiDia(
|
||||
)
|
||||
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
|
||||
const miosOportunidadesIds = new Set(
|
||||
capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId),
|
||||
capturas
|
||||
.filter((c) => c.oportunidadId)
|
||||
.map((c) => c.oportunidadId)
|
||||
.filter((id): id is string => id != null),
|
||||
)
|
||||
const miosApartadosTodos = apartados.filter((a) =>
|
||||
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
|
||||
@@ -387,7 +397,8 @@ export async function resumenMiDia(
|
||||
estado: a.estado,
|
||||
fechaLimite: a.fechaLimite,
|
||||
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
|
||||
pendienteSync: a.pendienteSync,
|
||||
// Los apartados leídos de Monday ya están sincronizados por definición
|
||||
pendienteSync: false,
|
||||
}))
|
||||
|
||||
const misApartados: ApartadoMiDia[] = miosApartados
|
||||
@@ -874,9 +885,9 @@ export function resumenMiDiaLocal(
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
unidadCodigo: '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: '',
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: null,
|
||||
pendienteSync: c.pendienteSync,
|
||||
})),
|
||||
|
||||
@@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia {
|
||||
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
|
||||
calificaLabel?: string | undefined // dropdown 'Lead Califica'
|
||||
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
|
||||
/** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */
|
||||
numDependientes?: string | undefined
|
||||
notas?: string | undefined // long_text 'COMENTARIOS'
|
||||
}
|
||||
|
||||
|
||||
@@ -75,12 +75,14 @@ describe('construirMiTablero', () => {
|
||||
captura({ board: 'OPORTUNIDADES' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
||||
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
expect(tablero.conversion.leads).toBe(2)
|
||||
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
||||
expect(tablero.conversion.oportunidades).toBe(1)
|
||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
||||
expect(tablero.conversion.vendidos).toBe(1)
|
||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||
})
|
||||
|
||||
it('calcula ventas del mes correctamente', () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MiTablero {
|
||||
oportunidades: number
|
||||
apartados: number
|
||||
vendidos: number
|
||||
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
|
||||
descartados: number
|
||||
}
|
||||
ventasMes: {
|
||||
monto: number
|
||||
@@ -125,6 +127,7 @@ export function construirMiTablero(
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||
).length
|
||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||
|
||||
// -- Ventas del mes --
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
@@ -183,7 +186,7 @@ export function construirMiTablero(
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
leadsPorDia,
|
||||
conversion: { leads, oportunidades, apartados, vendidos },
|
||||
conversion: { leads, oportunidades, apartados, vendidos, descartados },
|
||||
ventasMes,
|
||||
porOrigen,
|
||||
porDesarrollo,
|
||||
@@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { usuarioId },
|
||||
select: {
|
||||
leadId: true,
|
||||
board: true,
|
||||
etapa: true,
|
||||
origen: true,
|
||||
@@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
||||
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 usuarioFiltro = usuarioId ?? undefined
|
||||
|
||||
const [capturas, actividades, usuarios] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: whereUsuario,
|
||||
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||
select: {
|
||||
usuarioId: true,
|
||||
etapa: true,
|
||||
@@ -258,14 +262,14 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
||||
},
|
||||
}),
|
||||
prisma.actividadLocal.findMany({
|
||||
where: whereUsuario,
|
||||
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||
select: {
|
||||
usuarioId: true,
|
||||
estado: true,
|
||||
},
|
||||
}),
|
||||
prisma.usuario.findMany({
|
||||
where: whereUsuario ? { id: usuarioId } : { activo: true },
|
||||
where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
|
||||
@@ -168,23 +168,38 @@ function inicioDelDia(d: Date): Date {
|
||||
/**
|
||||
* Construye el resumen a partir de los datos crudos. Función pura: toda la
|
||||
* lógica de conteo/tasas se prueba sin red ni base de datos.
|
||||
*
|
||||
* `dias` (opcional, 7/30/90) acota leads y oportunidades a las capturas del
|
||||
* rango (por createdAt); inventario, cobranza y actividades son posición
|
||||
* actual y no se filtran. Sin `dias` se cuenta TODO el histórico (igual que antes).
|
||||
*/
|
||||
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes {
|
||||
export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number): ResumenReportes {
|
||||
const hoy = inicioDelDia(ahora)
|
||||
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const rangoValido = dias !== undefined && Number.isFinite(dias) && dias > 0
|
||||
const desdeRango = rangoValido
|
||||
? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
const enRango = (createdAt: string): boolean => {
|
||||
if (!desdeRango) return true
|
||||
const t = Date.parse(createdAt)
|
||||
return !Number.isNaN(t) && t >= desdeRango.getTime()
|
||||
}
|
||||
const leadsRango = datos.leads.filter((l) => enRango(l.createdAt))
|
||||
const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt))
|
||||
const creadosEn = (desde: Date): number =>
|
||||
datos.leads.filter((l) => {
|
||||
leadsRango.filter((l) => {
|
||||
const t = Date.parse(l.createdAt)
|
||||
return !Number.isNaN(t) && t >= desde.getTime()
|
||||
}).length
|
||||
|
||||
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
|
||||
const totalOps = datos.oportunidades.length
|
||||
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||
const apartadosOMas = datos.oportunidades.filter((o) =>
|
||||
const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa))
|
||||
const totalOps = oportunidadesRango.length
|
||||
const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||
const apartadosOMas = oportunidadesRango.filter((o) =>
|
||||
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
|
||||
).length
|
||||
const activas = datos.oportunidades.filter(
|
||||
const activas = oportunidadesRango.filter(
|
||||
(o) =>
|
||||
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
|
||||
o.etapa.trim().toUpperCase() !== 'GANADO',
|
||||
@@ -233,28 +248,28 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
||||
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
|
||||
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
|
||||
leads: {
|
||||
total: datos.leads.length,
|
||||
total: leadsRango.length,
|
||||
nuevosHoy: creadosEn(hoy),
|
||||
nuevosSemana: creadosEn(hace7dias),
|
||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||
porEstado: contarPor(leadsRango.map((l) => l.estado)),
|
||||
porOrigen: contarPor(leadsRango.map((l) => l.origen)),
|
||||
perfil: {
|
||||
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
|
||||
porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')),
|
||||
porRangoSalarial: contarPorNoVacios(
|
||||
datos.oportunidades
|
||||
oportunidadesRango
|
||||
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
||||
.filter((b): b is string => b !== null),
|
||||
),
|
||||
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
|
||||
porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')),
|
||||
porEdad: contarPorNoVacios(
|
||||
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
|
||||
oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -391,7 +406,8 @@ export class ReportesService {
|
||||
private readonly cacheMs: number
|
||||
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
|
||||
private readonly cargarDatosLocales: CargadorDatosLocales
|
||||
private cache: { resumen: ResumenReportes; expira: number } | null = null
|
||||
/** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */
|
||||
private cache: Map<string, { resumen: ResumenReportes; expira: number }> = new Map()
|
||||
|
||||
constructor(
|
||||
private readonly monday: MondayReportes,
|
||||
@@ -406,15 +422,18 @@ export class ReportesService {
|
||||
|
||||
/** Invalida el cache (útil tras escrituras que cambian los números) */
|
||||
invalidarCache(): void {
|
||||
this.cache = null
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
async resumen(): Promise<ResumenReportes> {
|
||||
async resumen(dias?: number): Promise<ResumenReportes> {
|
||||
const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined
|
||||
const clave = String(rango ?? 0)
|
||||
const ahoraMs = this.now()
|
||||
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
||||
const hit = this.cache.get(clave)
|
||||
if (hit && hit.expira > ahoraMs) return hit.resumen
|
||||
|
||||
const resumen = await this.cargarResumen(ahoraMs)
|
||||
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||
const resumen = await this.cargarResumen(ahoraMs, rango)
|
||||
this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs })
|
||||
return resumen
|
||||
}
|
||||
|
||||
@@ -423,7 +442,7 @@ export class ReportesService {
|
||||
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
|
||||
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
|
||||
*/
|
||||
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> {
|
||||
private async cargarResumen(ahoraMs: number, dias?: number): Promise<ResumenReportes> {
|
||||
if (this.salud.estaDisponible()) {
|
||||
try {
|
||||
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||
@@ -434,7 +453,7 @@ export class ReportesService {
|
||||
this.monday.getActividadesAbiertas(),
|
||||
this.cargarPlanes(),
|
||||
])
|
||||
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias)
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError && err.status === 502)) throw err
|
||||
// Monday se cayó a media lectura: cae al camino local
|
||||
@@ -444,6 +463,7 @@ export class ReportesService {
|
||||
const resumen = construirResumen(
|
||||
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
|
||||
new Date(ahoraMs),
|
||||
dias,
|
||||
)
|
||||
// En modo local, los datos son completos (no parciales): todas las métricas
|
||||
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó
|
||||
|
||||
Reference in New Issue
Block a user