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:
2026-09-05 05:37:52 +00:00
parent b40b8d4627
commit af2fb10956
6 changed files with 171 additions and 24 deletions

View File

@@ -76,6 +76,10 @@ AI_MIN_SCORE="0.3"
LEAD_ESCALADO_HORAS=48 LEAD_ESCALADO_HORAS=48
# User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado # User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado
MONDAY_COORDINADOR_USER_ID= MONDAY_COORDINADOR_USER_ID=
# User ID Monday de la cuenta COMPARTIDA de vendedores de piso (usan solo la
# app, todos ligados a una sola cuenta Monday). Con ella, Mi dia filtra "lo mio"
# por captura local y no por la columna people. 0/vacio = desactivado
MONDAY_VENDEDOR_COMPARTIDO_ID=
# Recordatorios de cobranza (pagos por vencer / vencidos) # Recordatorios de cobranza (pagos por vencer / vencidos)
# Cron del escaneo diario (default "30 9 * * *") # Cron del escaneo diario (default "30 9 * * *")

View File

@@ -51,6 +51,12 @@ export const config = {
escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')), escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')),
// User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado // User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado
coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')), 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 * * * *'), pollCron: env('POLL_CRON', '*/5 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'), recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
}, },

View File

@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js' import { mondayService } from '../services/monday.service.js'
import { resumenMiDia } from '../services/mi-dia.service.js' import { resumenMiDia } from '../services/mi-dia.service.js'
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js' import { AppError } from '../utils/errors.js'
/** /**
@@ -44,6 +45,17 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
orderBy: { createdAt: 'desc' }, 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) // Altas de clientes iniciadas por el usuario (registro local)
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
where: { capturadoPorId: usuario.id }, where: { capturadoPorId: usuario.id },
@@ -57,6 +69,9 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
nombresPorMondayId, nombresPorMondayId,
capturas, capturas,
clientes, clientes,
new Date(),
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
capturadosIds,
), ),
) )
} }

View File

@@ -562,3 +562,65 @@ describe('clasificadores de estado', () => {
expect(esEstadoFinalApartado('Pago Apartado')).toBe(false) 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'])
})
})

View File

@@ -208,10 +208,27 @@ export async function resumenMiDia(
createdAt: Date createdAt: Date
}[] = [], }[] = [],
ahora: Date = new 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> { ): Promise<ResumenMiDia> {
const miId = usuario.mondayUserId const miId = usuario.mondayUserId
const sinMapeo = miId === null 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([ const [leads, oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(), monday.getLeads(),
monday.getOportunidades(), monday.getOportunidades(),
@@ -224,26 +241,26 @@ export async function resumenMiDia(
const en7Dias = fechaLocal(ahora, 7) const en7Dias = fechaLocal(ahora, 7)
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) --- // --- Listas personales (por captura local + asignación Monday; ver esMio) ---
// "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no // "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
// terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente // Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
// primero y con su fuente para las acciones del CRM. // la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
const misLeadsLeads: LeadMiDia[] = sinMapeo // 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).
: leads const misLeadsLeads: LeadMiDia[] = leads
.filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado)) .filter((l) => esMio(l.vendedorIds, l.id) && !esEstadoTerminalLead(l.estado))
.map((l) => ({ .map((l) => ({
id: l.id, id: l.id,
nombre: l.nombre, nombre: l.nombre,
telefono: l.telefono, telefono: l.telefono,
email: l.email, email: l.email,
estado: l.estado, estado: l.estado,
origen: l.origen, origen: l.origen,
updatedAt: l.updatedAt, updatedAt: l.updatedAt,
fuente: 'lead', fuente: 'lead',
ultimoCambio: null, ultimoCambio: null,
})) }))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId)) const mios = oportunidades.filter((o) => esMio(o.asesorIds, o.id))
const misLeadsOportunidades: LeadMiDia[] = mios const misLeadsOportunidades: LeadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa)) .filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({ .map((o) => ({
@@ -280,9 +297,15 @@ export async function resumenMiDia(
})) }))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const miosApartados = sinMapeo // 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
: apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado)) 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([ const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
monday.getUltimosCambios( monday.getUltimosCambios(
@@ -414,7 +437,7 @@ export async function resumenMiDia(
})) }))
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades) // 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 = { const conteos = {
leadsNuevosSemana: leadsNuevosSemana:
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length + miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Vincula TODOS los usuarios con rol 'vendedor' del ecosistema a UNA sola
* cuenta de Monday (la cuenta compartida de vendedores de piso).
*
* Uso (desde api-gateway, donde vive la BD):
* cd api-gateway && node ../scripts/vincular-vendedores-compartido.js <mondayUserId>
*
* Ejemplo: node ../scripts/vincular-vendedores-compartido.js 106505920
*
* Después de correrlo, configura MONDAY_VENDEDOR_COMPARTIDO_ID=<mondayUserId>
* en el .env para que Mi día filtre por captura local (si no, todos verían
* los leads de todos por compartir la misma cuenta Monday).
*/
const { PrismaClient } = require('@prisma/client')
const mondayUserId = Number(process.argv[2])
if (!Number.isInteger(mondayUserId) || mondayUserId <= 0) {
console.error('Uso: node vincular-vendedores-compartido.js <mondayUserId>')
process.exit(1)
}
const prisma = new PrismaClient()
;(async () => {
const vendedores = await prisma.usuario.findMany({
where: { rol: 'vendedor', activo: true },
select: { id: true, nombre: true, mondayUserId: true },
})
console.log(`Vendedores activos: ${vendedores.length}`)
const r = await prisma.usuario.updateMany({
where: { rol: 'vendedor', activo: true },
data: { mondayUserId },
})
console.log(`Vinculados a Monday user ${mondayUserId}: ${r.count}`)
for (const v of vendedores) console.log(` - ${v.nombre} (antes: ${v.mondayUserId ?? 'sin vínculo'})`)
await prisma.$disconnect()
})()