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:
@@ -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 * * *")
|
||||||
|
|||||||
@@ -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 * * *'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
37
scripts/vincular-vendedores-compartido.js
Normal file
37
scripts/vincular-vendedores-compartido.js
Normal 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()
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user