Files
FRASA-CAS/api-gateway/src/services/mi-dia.service.ts
Consult AS Technology dc5e99ad98 fix(mi-dia): oportunidad en Aparto sale de Mis oportunidades
Al apartar, el seguimiento continúa en Mis apartados (pipeline Enkontrol),
así que 'APARTO' pasa a ser etapa terminal para la lista de trabajo (Mis
oportunidades y el merge de Mis leads). El conteo porEtapa del pipeline
personal la sigue incluyendo.
2026-09-05 01:07:18 +00:00

479 lines
17 KiB
TypeScript

import type { MondayService } from './monday.service.js'
import { config } from '../config/index.js'
/**
* Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy.
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
* Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
* la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
* local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
* dio de alta, con su estado actual (cruza contra ambos tableros por id).
*/
// 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',
'FIRMA CLIENTE (VENTA)', // la venta se concreta: pasa a Escrituración
]
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
export interface UsuarioMiDia {
nombre: string
rol: string
mondayUserId: number | null
}
export interface LeadMiDia {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
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 // estado ACTUAL en Monday; '' si el lead ya no existe en el board
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 item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */
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 evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
export interface ApartadoMiDia {
id: string
nombre: string
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
misActividadesHoy: 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[]
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: {
mondayUserId: number
nombre: string | null
leadsAsignados: number
/** Leads del vendedor creados en los últimos 7 días naturales */
leadsNuevosSemana: number
}[]
}
}
type MondayMiDia = Pick<
MondayService,
'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios'
>
const MS_DIA = 24 * 60 * 60 * 1000
/** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */
export function detalleEventoUltimoCambio(evento: string): string {
switch (evento) {
case 'create_pulse':
return 'Alta'
case 'create_update':
return 'Nota agregada'
case 'update_column_value':
case 'update_status':
return 'Actualización'
case 'move_to_group':
return 'Cambio de grupo'
default:
return evento
}
}
/** 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 async function resumenMiDia(
monday: MondayMiDia,
usuario: UsuarioMiDia,
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
nombresPorMondayId: Map<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}[] = [],
ahora: Date = new Date(),
): Promise<ResumenMiDia> {
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [leads, oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
])
const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7)
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
// "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no
// terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente
// primero y con su fuente para las acciones del CRM.
const misLeadsLeads: LeadMiDia[] = sinMapeo
? []
: leads
.filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado))
.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
updatedAt: l.updatedAt,
fuente: 'lead',
ultimoCambio: null,
}))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeadsOportunidades: LeadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad',
ultimoCambio: null,
}))
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
)
// "Último cambio" por item: UNA llamada al activity log por tablero (máx 3),
// agrupando los ids por fuente. El usuario se resuelve con el mapa
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
const itemsOportunidades: OportunidadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
etapa: o.etapa,
desarrollo: o.desarrollo ?? '',
modeloInteres: o.modeloInteres ?? '',
unidadCodigo: o.unidadCodigo,
updatedAt: o.updatedAt,
ultimoCambio: null,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const miosApartados = sinMapeo
? []
: apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado))
const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
monday.getUltimosCambios(
config.monday.boards.leads,
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
),
monday.getUltimosCambios(
config.monday.boards.oportunidades,
itemsOportunidades.map((o) => o.id),
),
monday.getUltimosCambios(
config.monday.boards.apartado,
miosApartados.map((a) => a.id),
),
])
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
for (const lead of misLeads) {
const cambio = cambiosPorFuente[lead.fuente].get(lead.id)
lead.ultimoCambio = cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null
}
for (const op of itemsOportunidades) {
const cambio = cambiosOportunidades.get(op.id)
op.ultimoCambio = cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null
}
// "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol,
// por fecha límite ascendente (sin fecha al final)
const misApartados: ApartadoMiDia[] = miosApartados
.map((a) => {
const cambio = cambiosApartados.get(a.id)
return {
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
estado: a.estado,
fechaLimite: a.fechaLimite,
ultimoCambio: cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null,
}
})
.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}
for (const o of mios) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday,
// cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a
// Leads; las de Alta Afluencia, a Oportunidades), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => {
const lead = leadsPorId.get(c.leadId)
const op = oportunidadesPorId.get(c.leadId)
return {
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: lead?.estado ?? op?.etapa ?? '',
origen: lead?.origen ?? op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// "Mis clientes" es local + cruce con Monday: el alta en progreso lleva
// proyecto '', el alta completada lleva el proyecto lleno. estadoApartado
// cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay
// apartado ligado o ya no existe en Monday)
const apartadosPorId = new Map(apartados.map((a) => [a.id, a]))
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 ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
const misActividadesHoy: ActividadMiDia[] = misActividades
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
.sort((a, b) => a.fecha.localeCompare(b.fecha))
.map((a) => ({ id: a.id, nombre: a.nombre, tipo: a.tipo, fecha: a.fecha, estado: a.estado }))
// Apartados por vencer es del pipeline completo (Enkontrol no lleva asesor people)
const apartadosPorVencer: ApartadoPorVencer[] = apartados
.filter(
(a) =>
a.fechaLimite !== '' &&
a.fechaLimite >= hoy &&
a.fechaLimite <= en7Dias &&
!esEstadoFinalApartado(a.estado),
)
.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite))
.map((a) => ({
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
fechaLimite: a.fechaLimite,
estado: a.estado,
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
}))
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const conteos = {
leadsNuevosSemana:
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length,
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
}
const resumen: ResumenMiDia = {
generadoEn: ahora.toISOString(),
sinMapeoMonday: sinMapeo,
misLeads,
misCapturas,
misClientes,
misActividadesHoy,
apartadosPorVencer,
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
conteos,
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
// Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor
// (etapa Afluencia) — lo que el round-robin aún no reparte
const sinAsignar =
leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO')
.length +
oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
// Leads y oportunidades comparten el conteo por vendedor (misma persona Monday)
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,
nombre: nombresPorMondayId.get(mondayUserId) ?? null,
leadsAsignados,
leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0,
}))
.sort((a, b) => b.leadsAsignados - a.leadsAsignados),
}
}
return resumen
}