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:
@@ -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')),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
Reference in New Issue
Block a user