From d4fc9ee8928d2138c39a7c02829d6d02aa0937dc Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 02:39:37 +0000 Subject: [PATCH] feat(round-robin): reparto solo con el equipo 'vendedores' de Monday MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obtenerVendedores ahora lee el team de Monday (MONDAY_RR_TEAM, default 'vendedores') con cache de 1 h; MONDAY_RR_USER_IDS queda como override manual. - Escrituras a columnas status resuelven el label real sin importar mayusculas: si renombran labels en la UI de Monday (paso con NUEVO→Nuevo) el API sigue escribiendo en vez de tronar con 502. - mapearLead: un status nunca escrito (value null) se lee como vacio; Monday regresaba el label default del tablero como texto y el round-robin creia que el lead estaba descartado. --- api-gateway/src/config/index.ts | 4 +- .../services/automatizaciones.service.test.ts | 18 ++++++-- .../src/services/automatizaciones.service.ts | 29 ++++++++---- .../src/services/monday.service.test.ts | 45 ++++++++++++++++++ api-gateway/src/services/monday.service.ts | 46 +++++++++++++++++-- 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 29b3d83..caca205 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -42,8 +42,10 @@ export const config = { enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false', // 'false' desactiva la creación de actividades en Monday (default true; false en tests) actividadesEnabled: env('ACTIVIDADES_ENABLED', 'true') !== 'false', - // IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace + // IDs de usuario Monday separados por coma; vacío = usar el equipo rrEquipo rrUserIds: env('MONDAY_RR_USER_IDS', ''), + // Equipo (team) de Monday cuyos miembros entran al round-robin + rrEquipo: env('MONDAY_RR_TEAM', 'vendedores'), inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')), // Horas que un lead asignado puede seguir en NUEVO/Intento de contacto antes de escalar escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')), diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts index 09b8eca..558c224 100644 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -110,7 +110,7 @@ function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = [] this.ventas.push(datos) return `vta-${this.ventas.length}` }, - async getUsuariosWorkspace() { + async getUsuariosEquipo(_nombre: string) { return VENDEDORES }, } @@ -244,9 +244,9 @@ describe('AutomatizacionesService.runRoundRobin', () => { expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }]) }) - it('cachea los vendedores del workspace en el estado (una sola llamada)', async () => { + it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => { const monday = mondayMock([]) - const spy = vi.spyOn(monday, 'getUsuariosWorkspace') + const spy = vi.spyOn(monday, 'getUsuariosEquipo') const state = stateEnMemoria() const svc = crearServicio(monday, state) const v1 = await svc.obtenerVendedores() @@ -254,6 +254,18 @@ describe('AutomatizacionesService.runRoundRobin', () => { expect(v1.map((v) => v.id)).toEqual([101, 102, 103]) expect(v2).toEqual(v1) expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0]?.[0]).toBe('vendedores') // equipo por default + }) + + it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => { + const monday = mondayMock([]) + const spy = vi.spyOn(monday, 'getUsuariosEquipo') + const state = stateEnMemoria() + state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }])) + const svc = crearServicio(monday, state) + const v = await svc.obtenerVendedores() + expect(v.map((x) => x.id)).toEqual([101, 102, 103]) + expect(spy).toHaveBeenCalledTimes(1) }) }) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index 1eba479..8eb5158 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -40,6 +40,8 @@ const CLAVE_CONTADOR_VENTAS = 'contador:ventasCreadas' const DIA_MS = 24 * 60 * 60 * 1000 const HORA_MS = 60 * 60 * 1000 +/** TTL del cache de la lista de vendedores del equipo (1 h) */ +const CACHE_VENDEDORES_MS = HORA_MS /** Store clave-valor inyectable (Prisma en prod, Map en tests) */ export interface StateStore { @@ -143,7 +145,7 @@ type MondayLeads = Pick< | 'getOportunidades' | 'asignarVendedorALead' | 'crearUpdateEnItem' - | 'getUsuariosWorkspace' + | 'getUsuariosEquipo' | 'crearActividad' | 'crearApartado' | 'crearVenta' @@ -248,20 +250,31 @@ export class AutomatizacionesService { } /** - * Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida; - * si no, usuarios no-guest del workspace (cacheados en la tabla de estado). + * Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida + * (override manual); si no, los miembros del equipo de Monday MONDAY_RR_TEAM + * (default "vendedores"). El cache dura 1 h: altas/bajas del equipo se + * reflejan solas sin redeploy. */ async obtenerVendedores(): Promise { if (config.automatizaciones.rrUserIds.trim() !== '') { return parseRrUserIds(config.automatizaciones.rrUserIds) } const cacheado = await this.state.get(CLAVE_RR_VENDEDORES) - if (cacheado) return JSON.parse(cacheado) as UsuarioMonday[] - const usuarios = await this.monday.getUsuariosWorkspace() - if (usuarios.length === 0) { - throw new AppError(502, 'Monday no devolvió usuarios asignables del workspace') + if (cacheado) { + try { + const parsed = JSON.parse(cacheado) as { ts?: number; usuarios?: UsuarioMonday[] } + if (parsed.ts !== undefined && parsed.usuarios !== undefined && Date.now() - parsed.ts < CACHE_VENDEDORES_MS) { + return parsed.usuarios + } + } catch { + // formato viejo (arreglo sin ts) → re-consulta abajo + } } - await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify(usuarios)) + const usuarios = await this.monday.getUsuariosEquipo(config.automatizaciones.rrEquipo) + if (usuarios.length === 0) { + throw new AppError(502, `El equipo "${config.automatizaciones.rrEquipo}" no tiene usuarios asignables`) + } + await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify({ ts: Date.now(), usuarios })) return usuarios } diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 3587af7..6cc4d8e 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -370,5 +370,50 @@ describe('MondayService', () => { const leads = await crearServicio().getLeads() expect(leads.map((l) => l.edad)).toEqual([null, null]) }) + + it('estado con value null (default del tablero, nunca escrito) se lee como vacío', async () => { + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] }) + return respuestaOk({ + boards: [ + { + items_page: { + cursor: null, + items: [ + // text trae el label default del board, pero value es null: no hay estado real + { ...itemLead('1'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Descartado', value: null }] }, + // estado real escrito: value presente → se usa text + { ...itemLead('2'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Nuevo', value: '{"index":1}' }] }, + ], + }, + }, + ], + }) + }) + + const leads = await crearServicio().getLeads() + expect(leads.map((l) => l.estado)).toEqual(['', 'Nuevo']) + }) + }) + + describe('escrituras status tolerantes a case (rename de labels en la UI)', () => { + it('cambiarValorColumna resuelve el label real sin importar mayúsculas', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('settings_str')) { + return respuestaOk({ + boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo","5":"Descartado"}}' }] }], + }) + } + if (body.query.includes('columns')) { + return respuestaOk({ boards: [{ columns: [{ id: 'color_e', title: 'Estado', type: 'status' }] }] }) + } + return respuestaOk({ change_column_value: { id: 'it-1' } }) + }) + + await crearServicio().cambiarValorColumna('b1', 'it-1', 'Estado', 'NUEVO') + + const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) + expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' }) + }) }) }) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 14eb495..583c449 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -370,6 +370,23 @@ export class MondayService { } } + /** + * Resuelve el label real de una columna status tolerando mayúsculas y + * minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras + * un rename en la UI). Sin match devuelve el valor tal cual (Monday dirá + * que el label no existe, como antes). + */ + private async resolverLabelStatus( + boardId: string, + col: ColumnaMeta, + valor: string | number | boolean, + ): Promise { + if (col.type !== 'status') return valor + const buscado = String(valor).trim().toUpperCase() + const labels = await this.labelsDeColumna(boardId, col.title) + return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor + } + private async construirColumnValues( boardId: string, valores: Record, @@ -378,7 +395,7 @@ export class MondayService { for (const [titulo, crudo] of Object.entries(valores)) { if (crudo === undefined || crudo === '') continue const col = await this.columnaId(boardId, titulo) - out[col.id] = this.valorPorTipo(col, crudo) + out[col.id] = this.valorPorTipo(col, await this.resolverLabelStatus(boardId, col, crudo)) } return JSON.stringify(out) } @@ -544,11 +561,12 @@ export class MondayService { valor: string | number | boolean, ): Promise { const col = await this.columnaId(boardId, tituloColumna) + const resuelto = await this.resolverLabelStatus(boardId, col, valor) await this.graphql( `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } }`, - { boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, valor)) }, + { boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, resuelto)) }, ) } @@ -706,6 +724,11 @@ export class MondayService { const colOrigen = cols.get('Origen') const colTelefono = cols.get('Teléfono') const colEmail = cols.get('Email') + // Cuando el status nunca se ha escrito, Monday devuelve value=null pero + // `text` muestra el label default del tablero (hoy "Descartado"): el estado + // real es vacío, no el default. Se lee `text` SOLO si hay value. + const estadoCv = colEstado ? porId.get(colEstado.id) : undefined + const estado = estadoCv?.value ? (estadoCv.text ?? '') : '' // Perfilamiento: lectura tolerante por título ('' / null si falta la columna) const textoDe = (titulo: string): string => { const col = cols.get(titulo) @@ -718,7 +741,7 @@ export class MondayService { nombre: item.name, createdAt: item.created_at, updatedAt: item.updated_at, - estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', + estado, origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '', telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', @@ -1083,6 +1106,23 @@ export class MondayService { .filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com')) .map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email })) } + + /** + * Miembros de un equipo (team) de Monday por nombre, case-insensitive + * (p. ej. "vendedores" para el round-robin). Mismo filtro que el workspace. + */ + async getUsuariosEquipo(nombre: string): Promise { + const data = await this.graphql<{ + teams: { name: string; users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[] }[] | null + }>(`query { teams { name users { id name email is_guest enabled } } }`) + const equipo = (data.teams ?? []).find( + (t) => t.name.trim().toUpperCase() === nombre.trim().toUpperCase(), + ) + if (!equipo) throw new AppError(500, `El equipo "${nombre}" no existe en Monday`) + return equipo.users + .filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com')) + .map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email })) + } } // ---------- DTOs de escritura ----------