feat(reportes): módulo de Reportes con 10 reportes, filtros y descarga Excel
This commit is contained in:
@@ -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})` : ''}`
|
||||
|
||||
328
api-gateway/src/controllers/reportes-datos.controller.ts
Normal file
328
api-gateway/src/controllers/reportes-datos.controller.ts
Normal 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(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user