fix(auditoria completa): bloqueos de Mi Dia, cobranza (saldos y fechas), captura (departamento y rol), contactos (privacidad), cotizador (QR) y validaciones de API

This commit is contained in:
2026-09-21 07:18:39 +00:00
parent 75956a709f
commit 5e9b2ac456
16 changed files with 259 additions and 71 deletions

View File

@@ -249,13 +249,12 @@ async function escribir(planesLeidos) {
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
}
// Si el maestro registra pagos por encima del plan declarado (filas con
// MESES/MENSUALIDAD vacíos pero grid lleno), el total del plan es lo pagado.
const totalPagadoAcumulado = pagosProg.reduce((a, p) => a + p.montoPagado, 0) +
// Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal
// (incluidos los standalone, que antes quedaban fuera y generaban saldo
// fantasma: el plan mostraba 100% pagado y saldo pendiente a la vez).
const totalProgramado = pagosProg.reduce((a, p) => a + p.monto, 0) +
standalone.reduce((a, s) => a + s.monto, 0)
if (totalPagadoAcumulado > plan.engancheTotal) {
plan.engancheTotal = Math.round(totalPagadoAcumulado * 100) / 100
}
plan.engancheTotal = Math.round(totalProgramado * 100) / 100
const creado = await prisma.planPago.create({ data: plan })

View File

@@ -1,10 +1,31 @@
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import * as capturaService from '../services/captura-departamento.service.js'
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
import { AppError, BadRequestError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
// ── Validación de campos por departamento ────────────────────────────
/**
* Cada campo debe pertenecer al departamento que se está guardando: si el
* cliente manda un campo de otro departamento (p. ej. una captura de Obra
* enviada como Ventas) se rechaza en vez de crear una fila basura.
*/
function validarCamposDelDepartamento(
departamento: string,
registros: { unidad: string; campo: string; valor: string | null }[],
): void {
const permitidos = new Set((CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => c.campo))
const invalidos = registros.filter((r) => !permitidos.has(r.campo))
if (invalidos.length > 0) {
const lista = invalidos.slice(0, 3).map((r) => `${r.campo} (unidad ${r.unidad})`).join(', ')
throw new BadRequestError(
`El campo no pertenece al departamento ${departamento}: ${lista}${invalidos.length > 3 ? '…' : ''}`,
)
}
}
// ── Middleware admin ─────────────────────────────────────────────────
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
@@ -68,6 +89,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
await capturaService.guardarCampo(
body.desarrollo,
@@ -102,6 +124,10 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
validarCamposDelDepartamento(
body.departamento,
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),
)
await capturaService.guardarBulk(
body.desarrollo,

View File

@@ -1,7 +1,26 @@
import type { Request, Response } from 'express'
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
import {
detalleContactoLocal,
esContactoDelUsuario,
listarContactosLocal,
exportarCSV,
} from '../services/contactos-local.service.js'
import { perfilCompleto } from '../services/contactos-detalle.service.js'
import { NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
/** Roles que ven el directorio completo de contactos (no sólo los suyos) */
const ROLES_VEN_TODO = ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta']
/**
* ¿Este token ve todos los contactos? Sin token (AUTH_ENABLED=false, modo
* local) se permite todo, igual que hace /mi-dia/resumen; con token, sólo los
* roles de dirección ven el directorio completo (un vendedor ve lo suyo).
*/
function veTodoElDirectorio(token: { sub?: string; rol?: string } | undefined): boolean {
if (token === undefined) return true
return esAdminOManager(token.rol) || (token.rol !== undefined && ROLES_VEN_TODO.includes(token.rol))
}
/**
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
@@ -12,7 +31,9 @@ import { NotFoundError } from '../utils/errors.js'
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
const paginaParam = Number(req.query['pagina'])
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1
res.json(await listarContactosLocal(req.usuarioToken?.sub, pagina))
// Dirección/administración ven el directorio completo; un vendedor sólo lo suyo
const usuarioId = veTodoElDirectorio(req.usuarioToken) ? undefined : req.usuarioToken?.sub
res.json(await listarContactosLocal(usuarioId, pagina))
}
/**
@@ -23,7 +44,13 @@ export async function listarContactosEndpoint(req: Request, res: Response): Prom
* cotizaciones locales).
*/
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
const detalle = await detalleContactoLocal(String(req.params['id']))
const contactoId = String(req.params['id'])
// Sin permiso de directorio, el contacto debe ser suyo
const sub = req.usuarioToken?.sub
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
throw new NotFoundError('Contacto no encontrado')
}
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
@@ -34,6 +61,10 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
*/
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
const sub = req.usuarioToken?.sub
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
throw new NotFoundError('Contacto no encontrado')
}
const perfil = await perfilCompleto(contactoId)
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
res.json(perfil)
@@ -47,6 +78,7 @@ export async function exportarContactosEndpoint(req: Request, res: Response): Pr
const csv = await exportarCSV({
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
estatus: (req.query['estatus'] as string) ?? undefined,
...(veTodoElDirectorio(req.usuarioToken) ? {} : { usuarioId: req.usuarioToken?.sub }),
})
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')

View File

@@ -113,16 +113,15 @@ describe('resumenMiDiaEndpoint (100% local)', () => {
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
})
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' })
it('los usuarios se consultan SIEMPRE (resuelven el vendedor asignado de cada fila)', async () => {
// Aunque el rol no tenga sección de equipo, los nombres son necesarios para
// mostrar "vendedor asignado" y el capturista en las tarjetas y la bitácora.
await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
expect(prismaMock.usuario.findUnique).toHaveBeenCalled()
})
it('un error de BD se propaga (no se traga nada)', async () => {

View File

@@ -53,15 +53,15 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
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 }))
}
// Usuarios activos: se cargan SIEMPRE. La sección Equipo sólo se arma para
// los roles con equipo, pero el nombre del vendedor asignado y el capturista
// de cada fila se resuelven con esta lista (sin ella la bitácora mostraba
// "sin asignar" a los propios vendedores).
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
const usuarios: UsuarioLocal[] = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }

View File

@@ -1,3 +1,4 @@
import { AppError } from '../utils/errors.js'
import { Router } from 'express'
import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
@@ -205,14 +206,29 @@ apiRouter.post('/borradores', guardarBorrador)
apiRouter.get('/borradores/:id', obtenerBorrador)
apiRouter.delete('/borradores/:id', eliminarBorrador)
apiRouter.post('/planes', crearPlan)
apiRouter.get('/planes', listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
apiRouter.get('/planes/:id', obtenerPlan)
apiRouter.get('/planes/:id/timeline', timelinePlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
// Cobranza: sólo roles con el módulo (admin/manager/dirección y los que
// gestionan venta). Un vendedor no ve los planes de todo el mundo.
/**
* Middleware: permite los roles cuyo perfil incluye la cobranza. El vendedor
* queda fuera (no ve planes de otros clientes ni puede registrar abonos).
*/
function requireModuloCobranza(req: import('express').Request, _res: import('express').Response, next: import('express').NextFunction): void {
const rol = req.usuarioToken?.rol
const permitidos = ['admin', 'manager', 'administracion_venta', 'direccion_general', 'director_ventas', 'escrituracion', 'coordinador', 'corporativo']
if (!rol || !permitidos.includes(rol)) {
throw new AppError(403, 'Tu rol no tiene acceso a cobranza')
}
next()
}
apiRouter.post('/planes', requireModuloCobranza, crearPlan)
apiRouter.get('/planes', requireModuloCobranza, listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', requireModuloCobranza, cobranzaHoy)
apiRouter.get('/planes/:id', requireModuloCobranza, obtenerPlan)
apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan)
apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota)
apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)

View File

@@ -35,6 +35,21 @@ export interface ListaContactos {
hayMas: boolean
}
/**
* ¿El contacto pertenece a este vendedor? Se comprueba directo en la BD
* (cliente capturado por él o captura suya), sin depender de la paginación.
*/
export async function esContactoDelUsuario(contactoId: string, usuarioId: string): Promise<boolean> {
const [cliente, captura] = await Promise.all([
prisma.cliente.findFirst({ where: { id: contactoId, capturadoPorId: usuarioId }, select: { id: true } }),
prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: contactoId }, { id: contactoId }], usuarioId },
select: { id: true },
}),
])
return cliente !== null || captura !== null
}
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
@@ -59,11 +74,14 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
const vistos = new Set<string>()
const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => {
const clave = claveTelefono(entrada.telefono)
if (clave !== '') {
if (vistos.has(clave)) return
vistos.add(clave)
}
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
// teléfono se duplicaban siempre)
const tel = claveTelefono(entrada.telefono)
const clave = tel !== ''
? tel
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
if (vistos.has(clave)) return
vistos.add(clave)
lista.push(entrada)
}
@@ -253,11 +271,18 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
*/
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
export async function exportarCSV(
filtros: { desarrollo?: string; estatus?: string; usuarioId?: string | undefined } = {},
): Promise<string> {
// El vendedor sólo exporta SUS contactos (dirección exporta todo)
const clientes = await prisma.cliente.findMany({
where: filtros.usuarioId ? { capturadoPorId: filtros.usuarioId } : {},
orderBy: { createdAt: 'desc' },
})
const capturas = await prisma.leadCaptura.findMany({
where: {
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
...(filtros.usuarioId ? { usuarioId: filtros.usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
})

View File

@@ -374,11 +374,16 @@ export class CrmLocalService {
nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono,
email: oportunidad.email,
origen: oportunidad.origen,
desarrollo: oportunidad.desarrollo,
modelo: oportunidad.modelo,
board: 'APARTADO',
etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson,
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
// calculaba en el JSON del alta y la columna quedaba vacía).
fechaLimite: fechaLimiteApartado(oportunidad.datosJson),
pendienteSync: true,
},
}),
@@ -445,7 +450,24 @@ export class CrmLocalService {
}
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
export const crmLocal = new CrmLocalService()/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
export const crmLocal = new CrmLocalService()/** Fecha límite de un apartado: la del JSON si existe, si no hoy + 15 días */
function fechaLimiteApartado(datosJson: string | null): Date {
try {
const datos = datosJson ? (JSON.parse(datosJson) as Record<string, unknown>) : {}
const fecha = datos['fechaLimite']
if (typeof fecha === 'string' && fecha.trim() !== '') {
const d = new Date(fecha.length === 10 ? `${fecha}T00:00:00.000Z` : fecha)
if (!Number.isNaN(d.getTime())) return d
}
} catch {
/* JSON corrupto: se calcula por defecto */
}
const limite = new Date()
limite.setDate(limite.getDate() + 15)
return limite
}
/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
function soloDigitos(telefono: string): string {
return telefono.replace(/\D/g, '')
}

View File

@@ -72,7 +72,10 @@ describe('resumenMiDiaLocal', () => {
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1'])
expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1'])
expect(r.misCapturas).toHaveLength(4) // loc_l1 y loc_o1 se excluyen (ya en misLeads/misOportunidades)
// "Mis capturas" son sólo altas del tablero Leads que no aparezcan ya en
// otro tab: loc_l1 ya está en misLeads y loc_l2 (Descartado) es la única
// que queda; las filas de OPORTUNIDADES y APARTADO ya no se repiten aquí.
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['loc_l2'])
// Lo que no existe en local va vacío/cero sin romper el front
expect(r.misActividadesHoy).toEqual([])
expect(r.apartadosPorVencer).toEqual([])

View File

@@ -364,7 +364,29 @@ export function resumenMiDiaLocal(
pendienteSync: c.pendienteSync,
}))
// "Mis capturas" son sólo las altas del tablero Leads (antes listaba TODAS las
// filas del usuario, así que el mismo cliente aparecía 2-3 veces con etapas
// de oportunidad/apartado).
// Todos los apartados, incluidos los terminales (Escrituración y cancelados)
const todosApartadosLocal: ApartadoMiDia[] = apartadosLocales
.filter((c) => c.board === 'APARTADO')
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: null,
pendienteSync: c.pendienteSync,
}))
const misCapturas: CapturaMiDia[] = capturas
.filter((c) => c.board === 'LEADS')
.map((c) => ({
leadId: c.leadId,
nombre: c.nombreLead,
@@ -375,13 +397,26 @@ export function resumenMiDiaLocal(
pendienteSync: c.pendienteSync,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// Excluir de "Mis capturas" los que ya aparecen en "Mis leads" u "Mis
// oportunidades" para no duplicar visualmente.
// Excluir de "Mis capturas" los que ya aparecen en cualquiera de los otros
// tabs (por id y por cliente, para no repetir a la misma persona cuando su
// lead se convirtió en oportunidad o apartado).
const claveCliente = (nombre: string, telefono: string): string =>
`${nombre.trim().toLowerCase()}|${(telefono ?? '').replace(/\D/g, '').slice(-10)}`
const idsEnOtrosTabsLocal = new Set([
...misLeads.map((l) => l.id),
...itemsOportunidades.map((o) => o.id),
...misApartados.map((a) => a.id),
...todosApartadosLocal.map((a) => a.id),
])
const misCapturasSinDuplicadosLocal = misCapturas.filter((c) => !idsEnOtrosTabsLocal.has(c.leadId))
const clavesEnOtrosTabs = new Set([
...misLeads.map((l) => claveCliente(l.nombre, l.telefono)),
...itemsOportunidades.map((o) => claveCliente(o.nombre, o.telefono)),
...misApartados.map((a) => claveCliente(a.nombre, a.telefono)),
...todosApartadosLocal.map((a) => claveCliente(a.nombre, a.telefono)),
])
const misCapturasSinDuplicadosLocal = misCapturas.filter(
(c) => !idsEnOtrosTabsLocal.has(c.leadId) && !clavesEnOtrosTabs.has(claveCliente(c.nombre, c.telefono)),
)
const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
const misClientes: ClienteMiDia[] = clientes
@@ -505,22 +540,7 @@ export function resumenMiDiaLocal(
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
// Todos los apartados incluyendo terminales (para Escrituración)
todosApartados: apartadosLocales
.filter((c) => c.board === 'APARTADO')
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: null,
pendienteSync: c.pendienteSync,
})),
todosApartados: todosApartadosLocal,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,