feat(round-robin): reparto solo con el equipo 'vendedores' de Monday

- 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.
This commit is contained in:
2026-09-04 02:39:37 +00:00
parent f382987fbf
commit d4fc9ee892
5 changed files with 127 additions and 15 deletions

View File

@@ -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')),

View File

@@ -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)
})
})

View File

@@ -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<UsuarioMonday[]> {
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
}
await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify(usuarios))
} catch {
// formato viejo (arreglo sin ts) → re-consulta abajo
}
}
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
}

View File

@@ -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' })
})
})
})

View File

@@ -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<string | number | boolean> {
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<string, string | number | boolean | undefined>,
@@ -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<void> {
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<UsuarioMonday[]> {
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 ----------