feat(reportes): módulo de Reportes con 10 reportes, filtros y descarga Excel

This commit is contained in:
2026-09-17 07:18:34 +00:00
parent 31f83b0504
commit 8bcb093fec
8 changed files with 1279 additions and 4 deletions

View File

@@ -36,6 +36,18 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
// para los reportes de cancelación (Cliente/Motivo).
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
if (esCancelacion && (body.motivo !== undefined || body.subcategoria !== undefined)) {
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: {
...(body.motivo !== undefined ? { motivo: body.motivo } : {}),
...(body.subcategoria !== undefined ? { submotivo: body.subcategoria } : {}),
},
})
}
res.json({ itemId, estado: body.estado })
const detalle = body.motivo
? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}`

View File

@@ -0,0 +1,328 @@
/**
* Datos crudos para el módulo de Reportes del tablero (10 reportes del CRM).
* Devuelven filas planas en JSON; el frontend arma la vista y el Excel.
*
* Acceso: solo admin, director_ventas y direccion_general (ven a todos los
* vendedores). Rango de fechas `desde`/`hasta` en YYYY-MM-DD (inclusive).
*/
import type { Request, Response } from 'express'
import { NextFunction } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { AppError } from '../utils/errors.js'
import { CAMPOS_POR_DEPARTAMENTO } from '../config/campos-departamento.js'
import { VOCABULARIO } from '../config/vocabulario.js'
// ── Acceso ────────────────────────────────────────────────────────────
export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void {
const rol = req.usuarioToken?.rol
if (rol !== 'admin' && rol !== 'director_ventas' && rol !== 'direccion_general') {
throw new AppError(403, 'No tienes acceso a los reportes')
}
next()
}
// ── Helpers ───────────────────────────────────────────────────────────
const rangoSchema = z.object({
desde: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
hasta: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
/** Rango [desde 00:00, hasta+1d 00:00) en UTC; undefined si no viene */
function rangoFechas(query: Record<string, unknown>): { gte?: Date; lt?: Date } {
const { desde, hasta } = rangoSchema.parse(query)
const rango: { gte?: Date; lt?: Date } = {}
if (desde) rango.gte = new Date(`${desde}T00:00:00.000Z`)
if (hasta) {
const fin = new Date(`${hasta}T00:00:00.000Z`)
fin.setUTCDate(fin.getUTCDate() + 1)
rango.lt = fin
}
return rango
}
const campoDesarrollo = (query: Record<string, unknown>): string | undefined => {
const d = String(query['desarrollo'] ?? '').trim()
return d !== '' ? d : undefined
}
// Etapas de cancelación por tablero (todas las etapas del embudo)
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
function perfilDeDatos(datosJson: string | null): Record<string, string> {
if (!datosJson) return {}
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
const plano: Record<string, string> = {}
for (const [k, v] of Object.entries(datos)) {
if (v == null || typeof v === 'object') continue
plano[k] = String(v)
}
return plano
} catch {
return {}
}
}
// ── 1. Leads (todas las etapas, separados por vendedor y total) ───────
export async function reporteLeads(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const vendedorId = String(req.query['vendedorId'] ?? '').trim()
const filas = await prisma.leadCaptura.findMany({
where: { createdAt: rango, ...(vendedorId !== '' ? { usuarioId: vendedorId } : {}) },
orderBy: [{ usuarioId: 'asc' }, { createdAt: 'asc' }],
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
vendedores: usuarios
.filter((u) => u.rol === 'vendedor' && u.activo)
.map((u) => ({ id: u.id, nombre: u.nombre })),
filas: filas.map((c) => ({
id: c.leadId,
tablero: c.board,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
etapa: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
ultimoCambio: c.updatedAt.toISOString(),
})),
})
}
// ── 2 y 4. Cancelaciones (Cliente/Motivo, todas las etapas) ───────────
export async function reporteCancelaciones(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const filas = await prisma.leadCaptura.findMany({
where: { etapa: { in: ETAPAS_CANCELACION }, updatedAt: rango },
orderBy: { updatedAt: 'desc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => ({
id: c.leadId,
cliente: c.nombreLead,
telefono: c.telefono ?? '',
motivo: c.motivo ?? '',
submotivo: c.submotivo ?? '',
etapa: c.etapa,
tablero: c.board,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.updatedAt.toISOString(),
})),
})
}
// ── 3. Afluencias (por desarrollo, con rango de fechas) ───────────────
export async function reporteAfluencias(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const desarrollo = campoDesarrollo(req.query)
const filas = await prisma.leadCaptura.findMany({
where: { board: 'OPORTUNIDADES', createdAt: rango, ...(desarrollo ? { desarrollo } : {}) },
orderBy: { createdAt: 'asc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => {
const perfil = perfilDeDatos(c.datosJson)
return {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
etapa: c.etapa,
califica: perfil['califica'] ?? '',
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
}
}),
})
}
// ── 5. Captura por departamento (completo o por desarrollo) ───────────
export async function reporteCaptura(req: Request, res: Response): Promise<void> {
const desarrollo = campoDesarrollo(req.query)
const filas = await prisma.capturaDepartamento.findMany({
where: desarrollo ? { desarrollo } : {},
orderBy: [{ desarrollo: 'asc' }, { unidadCodigo: 'asc' }, { departamento: 'asc' }, { campo: 'asc' }],
})
res.json({
desarrollos: desarrollo ? [desarrollo] : [...new Set(filas.map((f) => f.desarrollo))],
campos: CAMPOS_POR_DEPARTAMENTO,
filas: filas.map((f) => ({
desarrollo: f.desarrollo,
unidad: f.unidadCodigo,
departamento: f.departamento,
campo: f.campo,
valor: f.valor,
})),
})
}
// ── 6. Clientes en cobranza (por desarrollo y fechas) ─────────────────
export async function reporteCobranza(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const desarrollo = campoDesarrollo(req.query)
const planes = await prisma.planPago.findMany({
where: { fechaInicio: rango, ...(desarrollo ? { proyecto: desarrollo } : {}) },
include: { pagos: true },
orderBy: { fechaInicio: 'asc' },
})
const hoy = new Date()
res.json({
filas: planes.map((p) => {
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
const vencidos = p.pagos.filter(
(x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto,
)
const proximo = p.pagos
.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
return {
cliente: p.clienteNombre,
unidad: p.unidadCodigo,
desarrollo: p.proyecto,
fechaInicio: p.fechaInicio.toISOString(),
engancheTotal: Math.round(p.engancheTotal * 100) / 100,
pagado: Math.round(pagado * 100) / 100,
saldo: Math.round(saldo * 100) / 100,
pagosVencidos: vencidos.length,
montoVencido: Math.round(vencidos.reduce((a, x) => a + (x.monto - x.montoPagado), 0) * 100) / 100,
proximoVencimiento: proximo ? proximo.fechaVencimiento.toISOString() : '',
estatus: p.status === 'CANCELADO' ? 'CANCELADO' : saldo <= 0.005 ? 'LIQUIDADO' : 'ACTIVO',
}
}),
})
}
// ── 7. Desarrollos / disponibilidad (unidades y modelos) ──────────────
export async function reporteDisponibilidad(_req: Request, res: Response): Promise<void> {
const unidades = await prisma.unidad.findMany({
orderBy: [{ desarrollo: 'asc' }, { modelo: 'asc' }, { codigo: 'asc' }],
})
res.json({
filas: unidades.map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo ?? '',
estatus: u.status,
supConstruccion: u.supConstruccion ?? null,
precioLista: u.precioLista ?? null,
precioAcabados: u.precioAcabados ?? null,
})),
})
}
// ── 8. Contactos (toda su info) ───────────────────────────────────────
export async function reporteContactos(_req: Request, res: Response): Promise<void> {
const filas = await prisma.leadCaptura.findMany({ orderBy: [{ board: 'asc' }, { createdAt: 'desc' }] })
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => {
const perfil = perfilDeDatos(c.datosJson)
return {
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
tablero: c.board,
etapa: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
numDependientes: perfil['numDependientes'] ?? '',
edad: perfil['edad'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
tipoEmpleo: perfil['tipoEmpleo'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
credito: perfil['credito'] ?? '',
tipoEconomia: perfil['tipoEconomia'] ?? '',
califica: perfil['califica'] ?? '',
loQuierePara: perfil['loQuierePara'] ?? '',
}
}),
})
}
// ── 9. Clientes (toda su info) ────────────────────────────────────────
export async function reporteClientes(_req: Request, res: Response): Promise<void> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: clientes.map((c) => {
const perfil = perfilDeDatos(c.payloadJson)
return {
nombre: c.nombreCompleto,
curp: c.curp,
rfc: c.rfc,
email: c.email ?? '',
celular: c.celular ?? '',
proyecto: c.proyecto,
unidad: c.unidadCodigo ?? '',
ekontrol: perfil['ekontrol'] ?? perfil['usuarioEnkontrol'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
antiguedad: perfil['antiguedad'] ?? '',
ingresoMensual: perfil['ingresoMensual'] ?? perfil['ingresos'] ?? '',
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
fecha: c.createdAt.toISOString(),
}
}),
})
}
// ── 10. Escrituración (estatus y fecha de cada cliente) ───────────────
export async function reporteEscrituracion(_req: Request, res: Response): Promise<void> {
const filas = await prisma.leadCaptura.findMany({
where: { board: 'APARTADO', etapa: { in: [...FASES_ESCRITURACION] } },
orderBy: { updatedAt: 'desc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
// Unidad ligada vía Cliente (igual que Mi día)
const clientes = await prisma.cliente.findMany({
where: { mondayApartadoId: { not: null } },
select: { mondayApartadoId: true, unidadCodigo: true },
})
const unidadPorApartadoId = new Map(
clientes.filter((c) => c.mondayApartadoId !== null).map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']),
)
res.json({
filas: filas.map((c) => ({
cliente: c.nombreLead,
telefono: c.telefono ?? '',
unidad: unidadPorApartadoId.get(c.leadId) ?? '',
estatus: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fechaUltimoCambio: c.updatedAt.toISOString(),
fechaAlta: c.createdAt.toISOString(),
})),
})
}

View File

@@ -86,6 +86,18 @@ import {
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
import {
requireRolReportes,
reporteLeads,
reporteCancelaciones,
reporteAfluencias,
reporteCaptura,
reporteCobranza,
reporteDisponibilidad,
reporteContactos,
reporteClientes,
reporteEscrituracion,
} from '../controllers/reportes-datos.controller.js'
import { estadoSistema } from '../controllers/sistema.controller.js'
import {
requireAdmin,
@@ -253,6 +265,17 @@ apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint)
// Datos crudos para el módulo de Reportes (admin/dirección): los 10 reportes
apiRouter.get('/reportes/datos/leads', requireRolReportes, reporteLeads)
apiRouter.get('/reportes/datos/cancelaciones', requireRolReportes, reporteCancelaciones)
apiRouter.get('/reportes/datos/afluencias', requireRolReportes, reporteAfluencias)
apiRouter.get('/reportes/datos/captura', requireRolReportes, reporteCaptura)
apiRouter.get('/reportes/datos/cobranza', requireRolReportes, reporteCobranza)
apiRouter.get('/reportes/datos/disponibilidad', requireRolReportes, reporteDisponibilidad)
apiRouter.get('/reportes/datos/contactos', requireRolReportes, reporteContactos)
apiRouter.get('/reportes/datos/clientes', requireRolReportes, reporteClientes)
apiRouter.get('/reportes/datos/escrituracion', requireRolReportes, reporteEscrituracion)
// Estado del modo local (banner del front)
apiRouter.get('/sistema/estado', estadoSistema)