feat(monday): mover items al grupo correcto al cambiar de estado
Webhook change_column_value en los 5 tableros (Leads, Oportunidades, Unidades, Apartado, Contactos) + mapeo central estado->grupo: el API re-consulta el item y lo mueve con move_item_to_group (idempotente). El funnel mueve la oportunidad de Afluencia al crearla (Monday la crea en el grupo top del board). Incluye script de backfill one-off (249 items reubicados) y tests del mapeo y del webhook.
This commit is contained in:
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runRoundRobin: vi.fn(),
|
||||
escaneoOportunidades: vi.fn(),
|
||||
asegurarAfluencia: vi.fn(),
|
||||
moverAGrupo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/automatizaciones.service.js', () => ({
|
||||
@@ -20,6 +21,15 @@ vi.mock('../services/funnel.service.js', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/grupos-estado.service.js', () => ({
|
||||
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
|
||||
GRUPOS_POR_ESTADO: {
|
||||
'111': { columna: 'Estado', destinos: { NUEVO: 'topics' } },
|
||||
'222': { columna: 'Estado', destinos: { Ganado: 'group_mm6scqz3' } },
|
||||
},
|
||||
moverAGrupoSegunEstado: mocks.moverAGrupo,
|
||||
}))
|
||||
|
||||
vi.mock('../config/index.js', () => ({
|
||||
config: {
|
||||
monday: {
|
||||
@@ -43,6 +53,7 @@ beforeEach(() => {
|
||||
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
|
||||
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
|
||||
mocks.asegurarAfluencia.mockResolvedValue('op-1')
|
||||
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
|
||||
})
|
||||
|
||||
describe('webhookMonday', () => {
|
||||
@@ -113,6 +124,27 @@ describe('webhookMonday', () => {
|
||||
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update_column_value en Leads dispara el mover a grupo según estado', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 111, itemId: 999 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '111', '999')
|
||||
})
|
||||
|
||||
it('update_column_value en Oportunidades dispara escaneo Y mover a grupo', () => {
|
||||
const { res } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, itemId: 5 } }), res)
|
||||
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
|
||||
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '222', '5')
|
||||
})
|
||||
|
||||
it('update_column_value en un board sin mapeo no dispara el mover a grupo', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 333, itemId: 1 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.moverAGrupo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
|
||||
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
|
||||
const { res, sendStatus } = resFake()
|
||||
|
||||
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||
import { funnelService } from '../services/funnel.service.js'
|
||||
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
|
||||
const eventoSchema = z
|
||||
.object({
|
||||
@@ -86,12 +88,9 @@ export function webhookMonday(req: Request, res: Response): void {
|
||||
// en AutomatizacionState). El cron de polling queda como red de seguridad.
|
||||
// OJO: el webhook se registra como 'change_column_value' pero el payload
|
||||
// real llega como 'update_column_value'; se aceptan los tres nombres.
|
||||
if (
|
||||
(type === 'change_status_column_value' ||
|
||||
type === 'change_column_value' ||
|
||||
type === 'update_column_value') &&
|
||||
esBoardOportunidades
|
||||
) {
|
||||
const esEventoColumna =
|
||||
type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value'
|
||||
if (esEventoColumna && esBoardOportunidades) {
|
||||
void automatizacionesService
|
||||
.escaneoOportunidades()
|
||||
.then((r) => {
|
||||
@@ -107,5 +106,24 @@ export function webhookMonday(req: Request, res: Response): void {
|
||||
})
|
||||
}
|
||||
|
||||
// Cambio de status en cualquier tablero con mapeo estado → grupo: el item se
|
||||
// mueve al grupo que corresponde a su nuevo estado (regla de negocio: los
|
||||
// grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el
|
||||
// payload solo dispara y el servicio re-consulta el item antes de mover.
|
||||
// En Oportunidades convive con el escaneo (ambos disparan).
|
||||
if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) {
|
||||
const board = String(boardId)
|
||||
const id = String(idEvento)
|
||||
void moverAGrupoSegunEstado(mondayService, board, id)
|
||||
.then((r) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user