feat(mi-dia): botón Afluencia abre el formulario prellenado y actualiza la oportunidad

- Mis oportunidades: 'Afluencia →' navega a /client/?afluencia=<id> con
  nombre, contacto, desarrollo y modelo prellenados
- Alta Afluencia: con oportunidadId el envío ACTUALIZA la oportunidad en
  Monday (perfil completo + etapa Afluencia) en vez de crear duplicado;
  sin oportunidadId el flujo de captura nueva no cambia
- monday.service: actualizarOportunidadAfluencia (mismo builder de la
  creación + name); moverAGrupoSegunEstado tras actualizar
- Tests: 457 API + 16 mi-dia + 116 alta-clientes, verde
This commit is contained in:
2026-09-04 22:53:21 +00:00
parent cbd50e2066
commit 5d7c6f58cd
9 changed files with 266 additions and 22 deletions

View File

@@ -65,6 +65,10 @@ function mondayMock(
this.oportunidadesCreadas.push(datos)
return 'op-nueva-1'
},
oportunidadesActualizadas: [] as { id: string; datos: Record<string, unknown> }[],
async actualizarOportunidadAfluencia(id: string, datos: Record<string, unknown>) {
this.oportunidadesActualizadas.push({ id, datos })
},
async buscarLeadPorTelefono(_telefono: string) {
return opts.duplicadoLead ?? null
},
@@ -260,6 +264,33 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
espia.mockRestore()
})
it('con oportunidadId (botón "Afluencia" de Mi día): ACTUALIZA la oportunidad, sin duplicado ni validación de teléfono', async () => {
const monday = mondayMock()
const r = await crearAfluencia(monday, {
...datosBase,
oportunidadId: 'op-existente-3',
desarrollo: 'SENDA',
modelo: 'SOHO',
edad: 34,
notas: 'Perfil completado en punto de venta',
})
expect(r).toEqual({ id: 'op-existente-3', oportunidadId: 'op-existente-3', actualizada: true })
expect(monday.oportunidadesCreadas).toEqual([]) // no se duplica
expect(monday.oportunidadesActualizadas).toEqual([
{
id: 'op-existente-3',
datos: expect.objectContaining({
nombre: 'Lead Prueba',
telefono: '4771234567',
desarrolloLabel: 'SENDA',
modeloLabel: 'SOHO',
edad: 34,
notas: 'Perfil completado en punto de venta',
}),
},
])
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
const monday = mondayMock()
await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(

View File

@@ -62,6 +62,9 @@ const crearAfluenciaSchema = z.object({
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
// Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se
// ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva
oportunidadId: z.string().trim().min(1).optional(),
// Campos de calificación de la afluencia (opcionales; los labels fijos del
// front coinciden con los sembrados en Monday, no se validan contra el board)
modelo: z.string().trim().min(1).optional(),
@@ -165,6 +168,7 @@ type MondayLeads = Pick<
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'actualizarOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
@@ -176,7 +180,7 @@ type MondayLeads = Pick<
>
export type ResultadoCrearLead =
| { id: string; oportunidadId: string | null }
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/**
@@ -237,6 +241,38 @@ export async function crearAfluencia(
): Promise<ResultadoCrearLead> {
const boardOportunidades = config.monday.boards.oportunidades
// Con oportunidadId: el formulario venía prellenado desde Mis oportunidades
// (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en
// etapa 'Afluencia'; no se crea duplicado ni se valida teléfono.
if (datos.oportunidadId !== undefined) {
await monday.actualizarOportunidadAfluencia(datos.oportunidadId, {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err)
}
return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
@@ -429,6 +465,12 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
})
return
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
// hay captura nueva que registrar ni round-robin que correr
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.