feat(mi-dia): vendedores solo-app con cuenta Monday compartida
- Las listas personales de Mi día (leads, oportunidades, apartados) se calculan por captura local (LeadCaptura/Cliente + mapping LeadOportunidad) ADEMÁS de la columna people de Monday: la app ya es totalmente usable sin cuenta Monday - Config MONDAY_VENDEDOR_COMPARTIDO_ID: cuando el usuario está ligado a la cuenta compartida, la columna people se ignora para 'lo mío' (si no, todos verían los leads de todos) - scripts/vincular-vendedores-compartido.js: vincula todos los usuarios con rol vendedor a la cuenta Monday compartida - Tests: 478 API, verde; verificado en vivo con usuario sin Monday
This commit is contained in:
@@ -51,6 +51,12 @@ export const config = {
|
||||
escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')),
|
||||
// User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado
|
||||
coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')),
|
||||
// User ID de Monday de la cuenta COMPARTIDA de vendedores (los vendedores
|
||||
// de piso operan solo la app, todos ligados a una sola cuenta Monday).
|
||||
// Cuando un usuario del ecosistema está ligado a esa cuenta, sus listas
|
||||
// personales de Mi día se filtran por su captura local, NO por la columna
|
||||
// people (si no, todos verían los leads de todos). 0 = desactivado.
|
||||
vendedorCompartidoId: Number(env('MONDAY_VENDEDOR_COMPARTIDO_ID', '0')),
|
||||
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
||||
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { resumenMiDia } from '../services/mi-dia.service.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,17 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping
|
||||
// LeadOportunidad: conversión con "Agendo" y afluencias históricas)
|
||||
const mappings = await prisma.leadOportunidad.findMany({
|
||||
where: { leadId: { in: capturas.map((c) => c.leadId) } },
|
||||
})
|
||||
const capturadosIds = new Set([
|
||||
...capturas.map((c) => c.leadId),
|
||||
...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null),
|
||||
...mappings.map((m) => m.oportunidadId),
|
||||
])
|
||||
|
||||
// Altas de clientes iniciadas por el usuario (registro local)
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: { capturadoPorId: usuario.id },
|
||||
@@ -57,6 +69,9 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
nombresPorMondayId,
|
||||
capturas,
|
||||
clientes,
|
||||
new Date(),
|
||||
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
|
||||
capturadosIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -562,3 +562,65 @@ describe('clasificadores de estado', () => {
|
||||
expect(esEstadoFinalApartado('Pago Apartado')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumenMiDia — cuenta compartida / uso solo-app (sin Monday)', () => {
|
||||
const COMPARTIDO = 999000111
|
||||
|
||||
it('sin vínculo Monday: ve lo que capturó en la app (registro local)', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1' }), // capturado por mí (abajo)
|
||||
lead({ id: 'l2' }), // no es mío
|
||||
],
|
||||
oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-28T10:00:00Z' })], // capturada por mí
|
||||
})
|
||||
const capturas = [
|
||||
{ leadId: 'l1', nombreLead: 'Lead l1', telefono: null, createdAt: new Date('2026-08-27') },
|
||||
{ leadId: 'o1', nombreLead: 'Lead o1', telefono: null, createdAt: new Date('2026-08-27') },
|
||||
]
|
||||
const r = await resumenMiDia(monday, sinMapeo, new Map(), capturas, [], AHORA)
|
||||
expect(r.misLeads.map((l) => l.id)).toEqual(['o1', 'l1'])
|
||||
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o1'])
|
||||
})
|
||||
|
||||
it('cuenta compartida: NO ve lo asignado a la cuenta compartida, solo su captura local', async () => {
|
||||
const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO }
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1', vendedorIds: [COMPARTIDO] }), // asignado a la cuenta compartida, capturado por otro
|
||||
lead({ id: 'l2', vendedorIds: [COMPARTIDO] }), // asignado a la compartida, capturado por MÍ
|
||||
],
|
||||
})
|
||||
const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }]
|
||||
const r = await resumenMiDia(monday, compartido, new Map(), capturas, [], AHORA, COMPARTIDO)
|
||||
expect(r.misLeads.map((l) => l.id)).toEqual(['l2']) // l1 queda fuera
|
||||
})
|
||||
|
||||
it('cuenta NO compartida: ve capturas + asignados (comportamiento de siempre)', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1', vendedorIds: [MI_ID] }), // asignado a mí
|
||||
lead({ id: 'l2', vendedorIds: [OTRO_ID] }), // capturado por mí (abajo)
|
||||
lead({ id: 'l3', vendedorIds: [OTRO_ID] }), // ni asignado ni capturado
|
||||
],
|
||||
})
|
||||
const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }]
|
||||
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA, COMPARTIDO)
|
||||
expect(r.misLeads.map((l) => l.id).sort()).toEqual(['l1', 'l2'])
|
||||
})
|
||||
|
||||
it('mis apartados incluye los ligados a mis altas de cliente aunque el Vendedor sea la cuenta compartida', async () => {
|
||||
const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO }
|
||||
const monday = mondayMock({
|
||||
apartados: [
|
||||
apartado({ id: 'p1', vendedorIds: [COMPARTIDO] }), // apartado de MI alta (abajo)
|
||||
apartado({ id: 'p2', vendedorIds: [COMPARTIDO] }), // apartado de otro
|
||||
],
|
||||
})
|
||||
const clientes = [
|
||||
{ id: 'c1', nombreCompleto: 'Cliente p1', proyecto: 'SENDA', unidadCodigo: 'U-1', mondayApartadoId: 'p1', createdAt: new Date('2026-08-27') },
|
||||
]
|
||||
const r = await resumenMiDia(monday, compartido, new Map(), [], clientes, AHORA, COMPARTIDO)
|
||||
expect(r.misApartados.map((a) => a.id)).toEqual(['p1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,10 +208,27 @@ export async function resumenMiDia(
|
||||
createdAt: Date
|
||||
}[] = [],
|
||||
ahora: Date = new Date(),
|
||||
/** User ID de la cuenta Monday COMPARTIDA de vendedores (config); con ella la
|
||||
* columna people ya no distingue personas y "lo mío" viene de la captura local */
|
||||
compartidoId: number | null = null,
|
||||
/** ids de items Monday capturados por el usuario (leads + oportunidades
|
||||
* mapeadas); default: los leadId de `capturas` */
|
||||
capturadosIds?: Set<string>,
|
||||
): Promise<ResumenMiDia> {
|
||||
const miId = usuario.mondayUserId
|
||||
const sinMapeo = miId === null
|
||||
|
||||
// "Mío" = lo que capturé en la app (registro local) o lo que Monday me tiene
|
||||
// asignado (columna people). Con la cuenta compartida, people ya no distingue
|
||||
// personas: solo cuenta la captura local. Funciona SIN vínculo Monday.
|
||||
const idsCapturados = capturadosIds ?? new Set(capturas.map((c) => c.leadId))
|
||||
const esCompartido = miId !== null && compartidoId !== null && compartidoId > 0 && miId === compartidoId
|
||||
const esMio = (peopleIds: number[], itemId: string): boolean => {
|
||||
if (idsCapturados.has(itemId)) return true
|
||||
if (esCompartido || miId === null) return false
|
||||
return peopleIds.includes(miId)
|
||||
}
|
||||
|
||||
const [leads, oportunidades, actividades, apartados] = await Promise.all([
|
||||
monday.getLeads(),
|
||||
monday.getOportunidades(),
|
||||
@@ -224,26 +241,26 @@ export async function resumenMiDia(
|
||||
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))
|
||||
// --- Listas personales (por captura local + asignación Monday; ver esMio) ---
|
||||
// "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).
|
||||
const misLeadsLeads: LeadMiDia[] = leads
|
||||
.filter((l) => esMio(l.vendedorIds, l.id) && !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 = oportunidades.filter((o) => esMio(o.asesorIds, o.id))
|
||||
const misLeadsOportunidades: LeadMiDia[] = mios
|
||||
.filter((o) => !esEtapaTerminal(o.etapa))
|
||||
.map((o) => ({
|
||||
@@ -280,9 +297,15 @@ export async function resumenMiDia(
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
|
||||
const miosApartados = sinMapeo
|
||||
? []
|
||||
: apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado))
|
||||
// Apartados ligados a MIS altas de cliente (registro local): con la cuenta
|
||||
// compartida o sin vínculo Monday, esta liga local es la que personaliza
|
||||
const apartadosDeMisClientes = new Set(
|
||||
clientes.map((c) => c.mondayApartadoId).filter((id): id is string => id !== null),
|
||||
)
|
||||
const miosApartados = apartados.filter(
|
||||
(a) =>
|
||||
(esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id)) && !esEstadoFinalApartado(a.estado),
|
||||
)
|
||||
|
||||
const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
|
||||
monday.getUltimosCambios(
|
||||
@@ -414,7 +437,7 @@ export async function resumenMiDia(
|
||||
}))
|
||||
|
||||
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
|
||||
const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
|
||||
const miosLeadRows = leads.filter((l) => esMio(l.vendedorIds, l.id))
|
||||
const conteos = {
|
||||
leadsNuevosSemana:
|
||||
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
|
||||
|
||||
Reference in New Issue
Block a user