Files
FRASA-CAS/api-gateway/src/services/mi-dia.service.ts

525 lines
17 KiB
TypeScript

/**
* Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy.
* 100% local: todo se construye desde la tabla LeadCaptura (los tabs del
* embudo), Cliente (mis clientes) y ActividadLocal (agenda). El "mío" se
* resuelve por el usuarioId de cada fila.
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo
// activo del asesor en Mis oportunidades. 'APARTO' incluido: al apartar, el
// seguimiento continúa en Mis apartados (pipeline Enkontrol).
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO', 'APARTO']
// Estados del board Apartado que cierran el pipeline Enkontrol
export const ESTADOS_FINALES_APARTADO = [
'ESCRITURADO',
'CANCELACION',
'CANCELACION APARTADO',
'CANCELADO',
'LIBERADO',
'FORMALIZADO',
'CANCELO',
'FIRMA CLIENTE',
'VENTA',
'SEGUIMIENTO',
'INTEGRACION',
'X AUTORIZAR',
'AUTORIZADO',
'X ESCRITURAR',
'TITULADO',
]
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
const MS_DIA = 24 * 60 * 60 * 1000
/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */
function fechaLocal(base: Date, dias: number): string {
const d = new Date(base.getTime() + dias * MS_DIA)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
}
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
export function esEstadoFinalApartado(estado: string): boolean {
return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase())
}
export interface UsuarioMiDia {
nombre: string
rol: string
}
export interface LeadMiDia {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
desarrollo?: string
updatedAt: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
export interface ActividadMiDia {
id: string
nombre: string
tipo: string
fecha: string
estado: string
}
export interface ApartadoPorVencer {
id: string
nombre: string
unidadCodigo: string
fechaLimite: string
estado: string
diasRestantes: number
}
export interface CapturaMiDia {
leadId: string
nombre: string
telefono: string
estado: string
origen: string
capturadoEn: string // ISO
}
export interface ClienteMiDia {
id: string
nombre: string
proyecto: string // '' = alta aún en captura
unidadCodigo: string
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
capturadoEn: string // ISO
/** Estado actual del apartado ligado; null si no hay apartado ligado */
estadoApartado: string | null
}
/** Oportunidad asignada al asesor (tab "Mis oportunidades" de Mi día) */
export interface OportunidadMiDia {
id: string
nombre: string
telefono: string
email: string
etapa: string // label de la columna status 'Estado'
desarrollo: string
modeloInteres: string
unidadCodigo: string
updatedAt: string
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
fechaCita?: string | undefined
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
export interface ApartadoMiDia {
id: string
nombre: string
telefono: string
email: string
origen: string
desarrollo: string
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
export interface ResumenMiDia {
generadoEn: string
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
todasMisActividades: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
/** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] }
/** MIS apartados (Vendedor = yo), en estados no finales del pipeline Enkontrol */
misApartados: ApartadoMiDia[]
/** Todos los apartados incluyendo terminales (para Escrituración) */
todosApartados: ApartadoMiDia[]
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
}
// Solo roles coordinador/corporativo/admin
equipo?: {
leadsNuevosSinAsignar: number
porVendedor: {
usuarioId: string
nombre: string | null
leadsAsignados: number
/** Leads del vendedor creados en los últimos 7 días naturales */
leadsNuevosSemana: number
}[]
}
}
/**
* Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El
* controller pasa las filas de Prisma ya filtradas por el usuario; la forma es
* estructural para que los tests usen fixtures llanas.
*/
export interface FilaLocalMiDia {
leadId: string
nombreLead: string
telefono: string | null
email: string | null
origen: string | null
desarrollo: string | null
modelo: string | null
board: string // LEADS | OPORTUNIDADES | APARTADO
etapa: string
/** Fecha límite del apartado (para "Apartados por vencer") */
fechaLimite: Date | null
/** Perfilamiento completo de afluencia (JSON serializado) */
datosJson: string | null
/** Usuario del ecosistema que capturó el lead (LeadCaptura.usuarioId) */
usuarioId: string
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
mondayItemId: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
}
/** Cliente local tal como lo necesita el resumen */
export interface ClienteLocalMiDia {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}
/** "Último cambio" derivado del updatedAt local; el quién es el capturista ('app' si no hay nombre) */
function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) {
return {
fechaISO: fila.updatedAt.toISOString(),
usuario: nombreUsuario.trim() !== '' ? nombreUsuario : 'app',
detalle: 'Actualización',
}
}
const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number =>
b.updatedAt.localeCompare(a.updatedAt)
/**
* Actividad local tal como la necesita Mi día (tabla ActividadLocal)
*/
export interface ActividadLocalMiDia {
id: string
titulo: string
descripcion: string | null
usuarioId: string
leadCapturaId: string | null
fechaVencimiento: Date | null
fechaCierre: Date | null
estado: string
tipo: string
}
/** Usuario local tal como lo necesita la sección equipo (tabla Usuario) */
export interface UsuarioLocal {
id: string
nombre: string
rol: string
activo: boolean
}
/**
* ResumenMiDia COMPLETO construido solo con datos locales: Mis leads (board
* LEADS, etapa no terminal), Mis oportunidades (board OPORTUNIDADES), Mis
* apartados (board APARTADO + unidad ligada vía Cliente), Mis capturas, Mis
* clientes.
*
* Parámetros opcionales:
* - `actividades`: actividades locales del usuario (tabla ActividadLocal)
* - `usuarios`: todos los usuarios activos (para la sección equipo)
*/
export function resumenMiDiaLocal(
usuario: UsuarioMiDia,
capturas: FilaLocalMiDia[],
clientes: ClienteLocalMiDia[],
ahora: Date = new Date(),
actividades: ActividadLocalMiDia[] = [],
usuarios: UsuarioLocal[] = [],
): ResumenMiDia {
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// Resolución del capturista real de cada fila (no el usuario que ve la pantalla)
const nombrePorUsuarioId = new Map(usuarios.map((u) => [u.id, u.nombre]))
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
// "Mis leads" solo muestra leads (board LEADS), no oportunidades
const misLeads: LeadMiDia[] = leadsLocales
.filter((c) => !esEstadoTerminalLead(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
updatedAt: c.updatedAt.toISOString(),
fuente: 'lead' as const,
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
// (oportunidades se muestran exclusivamente en misOportunidades)
// Conteo por etapa de TODAS las oportunidades (incluye terminales)
const porEtapa: Record<string, number> = {}
for (const o of oportunidadesLocales) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => {
let unidadCodigo = ''
let fechaCita: string | undefined
if (c.datosJson) {
try {
const datos = JSON.parse(c.datosJson) as Record<string, unknown>
if (typeof datos.unidadCodigo === 'string' && datos.unidadCodigo !== '') unidadCodigo = datos.unidadCodigo
else if (typeof datos.desarrollo === 'string' && datos.desarrollo !== '') unidadCodigo = datos.desarrollo
if (typeof datos.fechaCita === 'string') fechaCita = datos.fechaCita
} catch { /* datosJson corrupto: ignorar */ }
}
return {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
unidadCodigo,
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
fechaCita,
pendienteSync: c.pendienteSync,
}
})
.sort(porUpdatedAtDesc)
// La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada)
const unidadPorApartadoId = new Map(
clientes
.filter((c) => c.mondayApartadoId !== null)
.map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']),
)
const misApartados: ApartadoMiDia[] = apartadosLocales
.filter((c) => !esEstadoFinalApartado(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
}))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => ({
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: c.etapa,
origen: c.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
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.
const idsEnOtrosTabsLocal = new Set([
...misLeads.map((l) => l.id),
...itemsOportunidades.map((o) => o.id),
])
const misCapturasSinDuplicadosLocal = misCapturas.filter((c) => !idsEnOtrosTabsLocal.has(c.leadId))
const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
proyecto: c.proyecto,
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
estadoApartado:
c.mondayApartadoId != null ? (etapaApartadoPorId.get(c.mondayApartadoId) ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// --- Mis actividades hoy (el controller ya filtra por usuarioId) ---
const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7)
const actividadesAbiertasOrdenadas = actividades
.filter((a) => a.estado.toUpperCase() === 'ABIERTA')
.sort((a, b) => {
const fa = a.fechaVencimiento?.toISOString() ?? '9999'
const fb = b.fechaVencimiento?.toISOString() ?? '9999'
return fa.localeCompare(fb)
})
.map((a) => ({
id: a.id,
nombre: a.titulo,
tipo: a.tipo,
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
estado: a.estado,
}))
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas
.filter((a) => {
if (a.fecha === '') return true
return a.fecha <= manana
})
// --- Apartados por vencer ---
const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales
.filter((c) => {
if (esEstadoFinalApartado(c.etapa)) return false
if (!c.fechaLimite) return false
const fv = c.fechaLimite.toISOString().slice(0, 10)
return fv >= hoy && fv <= en7Dias
})
.sort((a, b) => {
const fa = a.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31'
const fb = b.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31'
return fa.localeCompare(fb)
})
.map((c) => {
const fv = c.fechaLimite!.toISOString().slice(0, 10)
return {
id: c.leadId,
nombre: c.nombreLead,
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
fechaLimite: fv,
estado: c.etapa,
diasRestantes: Math.round(
(Date.parse(`${fv}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA,
),
}
})
// --- Conteo de actividades abiertas ---
const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length
// --- Equipo (solo coordinador/admin/corporativo) ---
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
let equipo: NonNullable<ResumenMiDia['equipo']> | undefined
if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) {
const Leads = capturas.filter((c) => c.board === 'LEADS')
const Oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES')
const idsVendedores = new Set(usuarios.filter((u) => u.rol === 'vendedor' && u.activo).map((u) => u.id))
const sinAsignarLeads = Leads.filter(
(c) =>
!esEstadoTerminalLead(c.etapa) &&
c.etapa.toUpperCase() === 'NUEVO' &&
!idsVendedores.has(c.usuarioId),
).length
const sinAsignarOportunidades = Oportunidades.filter(
(c) => c.etapa.toUpperCase() === 'AFLUENCIA' && !idsVendedores.has(c.usuarioId),
).length
const porVendedor: NonNullable<ResumenMiDia['equipo']>['porVendedor'] = usuarios
.filter((u) => u.rol === 'vendedor' && u.activo)
.map((u) => {
const leadsAsignados = Leads.filter(
(c) => c.usuarioId === u.id && !esEstadoTerminalLead(c.etapa),
).length
const leadsNuevosSemana = Leads.filter(
(c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs,
).length
return {
usuarioId: u.id,
nombre: u.nombre,
leadsAsignados,
leadsNuevosSemana,
}
})
.sort((a, b) => b.leadsAsignados - a.leadsAsignados)
equipo = {
leadsNuevosSinAsignar: sinAsignarLeads + sinAsignarOportunidades,
porVendedor,
}
}
return {
generadoEn: ahora.toISOString(),
misLeads,
misCapturas: misCapturasSinDuplicadosLocal,
misClientes,
misActividadesHoy,
todasMisActividades,
apartadosPorVencer,
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 ?? '',
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: null,
pendienteSync: c.pendienteSync,
})),
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,
).length,
actividadesAbiertas,
},
...(equipo !== undefined ? { equipo } : {}),
}
}