fix(apartados): el apartado se crea al momento de dar Apartó

El endpoint POST /oportunidades/:id/etapa en 'Aparto' ahora garantiza la
creación del apartado antes de responder (asegurarApartado con reintentos:
Monday tarda ~1s en propagar la etapa). Antes dependía del webhook y Mis
apartados recargaba antes de que existiera. El webhook y el cron quedan
como red de seguridad para cambios hechos directo en Monday (dedup
compartido, idempotente).
This commit is contained in:
2026-09-05 01:21:18 +00:00
parent dc5e99ad98
commit b40b8d4627
3 changed files with 87 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -127,5 +128,17 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
try {
await automatizacionesService.asegurarApartado(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}

View File

@@ -704,3 +704,56 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada
})
})
describe('AutomatizacionesService.asegurarApartado (botón Aparto de Mi día)', () => {
it('dedup: si ya existe el apartado, lo devuelve sin tocar Monday', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
state.mapa.set('apartado-creado:10', 'apt-viejo')
const svc = crearServicio(monday, state)
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-viejo')
expect(monday.apartados).toEqual([])
})
it('crea el apartado en la primera pasada si la etapa ya se ve', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-1')
expect(monday.apartados).toHaveLength(1)
})
it('reintenta cuando Monday aún no propaga la etapa (read-after-write)', async () => {
// La oportunidad se lee en 'Afluencia' en la primera pasada y en 'Aparto'
// en la segunda (así se comporta Monday ~1s después del cambio)
const op = oportunidad('10', { etapa: 'Afluencia' })
const oportunidades = [op]
const monday = mondayMock([], oportunidades)
const svc = crearServicio(monday, stateEnMemoria())
const original = monday.getOportunidades.bind(monday)
let llamadas = 0
monday.getOportunidades = async () => {
llamadas++
if (llamadas >= 2) op.etapa = 'Aparto'
return original()
}
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-1')
expect(llamadas).toBeGreaterThanOrEqual(2)
expect(monday.apartados).toHaveLength(1)
})
it('agota los reintentos y devuelve null si nunca se ve en Aparto', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Cotizador' })])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.asegurarApartado('10', 2, 0)
expect(r).toBeNull()
expect(monday.apartados).toEqual([])
})
})

View File

@@ -582,6 +582,27 @@ export class AutomatizacionesService {
return resultado
}
/**
* Garantiza el apartado de UNA oportunidad que acaba de pasar a 'Aparto'
* (botón de Mi día). Monday tarda ~1s en propagar el cambio de etapa
* (read-after-write), así que el escaneo se repite unas veces hasta que la
* oportunidad se lea en 'Aparto' o el dedup confirme la creación. Devuelve
* el apartadoItemId o null si no se pudo.
*/
async asegurarApartado(oportunidadId: string, reintentos = 3, esperaMs = 1200): Promise<string | null> {
const previo = await this.state.get(claveApartadoCreado(oportunidadId))
if (previo !== null) return previo
for (let intento = 0; intento < reintentos; intento++) {
const r = await this.escaneoOportunidades()
const creado = r.apartadosCreados.find((a) => a.oportunidadId === oportunidadId)
if (creado) return creado.apartadoItemId
const tras = await this.state.get(claveApartadoCreado(oportunidadId))
if (tras !== null) return tras
if (intento < reintentos - 1) await new Promise((res) => setTimeout(res, esperaMs))
}
return null
}
/** Estado actual para el endpoint admin */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)