From b40b8d4627542c413db25130a2c058bb77ed177f Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 5 Sep 2026 01:21:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(apartados):=20el=20apartado=20se=20crea=20a?= =?UTF-8?q?l=20momento=20de=20dar=20Apart=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../controllers/oportunidades.controller.ts | 13 +++++ .../services/automatizaciones.service.test.ts | 53 +++++++++++++++++++ .../src/services/automatizaciones.service.ts | 21 ++++++++ 3 files changed, 87 insertions(+) diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index 9e12f07..816d9e5 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -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 } : {}) }) } diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts index ddf86ac..92d491c 100644 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -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([]) + }) +}) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index 273a7b2..e770058 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -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 { + 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> { const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)