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

@@ -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,