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:
@@ -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 } : {}) })
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user