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:
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user