From af2fb10956c751c1ecf32d97e222a2ea00108304 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 5 Sep 2026 05:37:52 +0000 Subject: [PATCH] feat(mi-dia): vendedores solo-app con cuenta Monday compartida MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 4 ++ api-gateway/src/config/index.ts | 6 ++ .../src/controllers/mi-dia.controller.ts | 15 ++++ .../src/services/mi-dia.service.test.ts | 62 ++++++++++++++++ api-gateway/src/services/mi-dia.service.ts | 71 ++++++++++++------- scripts/vincular-vendedores-compartido.js | 37 ++++++++++ 6 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 scripts/vincular-vendedores-compartido.js diff --git a/.env.example b/.env.example index 4c7f2e7..e527ac9 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,10 @@ AI_MIN_SCORE="0.3" LEAD_ESCALADO_HORAS=48 # User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado 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) # Cron del escaneo diario (default "30 9 * * *") diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index caca205..57aa94c 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -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 * * *'), }, diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 875a60c..735c34b 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -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, ), ) } diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index f90a864..c94d618 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -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']) + }) +}) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 75b042e..ace6ee0 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -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, ): Promise { 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 + diff --git a/scripts/vincular-vendedores-compartido.js b/scripts/vincular-vendedores-compartido.js new file mode 100644 index 0000000..974258a --- /dev/null +++ b/scripts/vincular-vendedores-compartido.js @@ -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 + * + * Ejemplo: node ../scripts/vincular-vendedores-compartido.js 106505920 + * + * Después de correrlo, configura MONDAY_VENDEDOR_COMPARTIDO_ID= + * 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 ') + 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() +})()