feat(mi-dia): tabs Mis oportunidades/Mis apartados con fases y cotizador vinculado
- Resumen Mi día: misOportunidades.items (lista de trabajo con perfil y último cambio) y misApartados (Vendedor = yo, estados no finales) - Nuevos endpoints: POST /oportunidades/:id/etapa (Agendo Cita, Afluencia, Cotizador, Aparto, No Asistio, Abandono; con fecha de cita) y POST /apartados/:id/estado (pipeline Enkontrol: Pago Apartado → Firma Cliente (Venta) / Cancelacion Apartado); ambos mueven el item al grupo Monday correspondiente - Mi día: tabs Mis leads / Mis oportunidades / Mis apartados; botón Cotizar abre /quote con el cliente prellenado y la oportunidad ligada - Cotizador: prefill desde query params (cliente, teléfono, email, desarrollo, unidad) y envío de oportunidadId; al guardar, el API actualiza ESE item (Precio final, Tipo propuesta, etapa Cotizador) en vez de buscar por teléfono o crear duplicado - grupos-estado: 'Cancelacion Apartado' → Historico - Estados finales de apartado: agrega Firma Cliente (Venta) y Cancelacion Apartado - Tests: 455 API + 14 mi-dia + 88 cotizador, todo en verde
This commit is contained in:
68
api-gateway/src/controllers/apartados.controller.test.ts
Normal file
68
api-gateway/src/controllers/apartados.controller.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cambiarEstadoApartado,
|
||||
FASES_APARTADO_PERMITIDAS,
|
||||
} from './apartados.controller.js'
|
||||
|
||||
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
||||
function mondayMock(grupoActual = 'topics') {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
movimientos: [] as { itemId: string; grupo: string }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
async columnaIdPorTitulo(_boardId: string, titulo: string) {
|
||||
return titulo === 'Estado' ? 'status' : null
|
||||
},
|
||||
async getItemConGrupo(itemId: string) {
|
||||
return {
|
||||
id: itemId,
|
||||
name: 'Cliente Demo',
|
||||
groupId: grupoActual,
|
||||
column_values: [
|
||||
{ id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' },
|
||||
],
|
||||
}
|
||||
},
|
||||
async moverItemAGrupo(itemId: string, grupo: string) {
|
||||
this.movimientos.push({ itemId, grupo })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
|
||||
it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' },
|
||||
])
|
||||
// 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve
|
||||
expect(monday.movimientos).toEqual([])
|
||||
})
|
||||
|
||||
it('Firma Cliente (Venta) mueve el item a "Clientes Activos"', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoApartado(monday, 'apt-2', 'Firma Cliente (Venta)')
|
||||
expect(monday.movimientos).toEqual([{ itemId: 'apt-2', grupo: 'group_mm6sx3rn' }])
|
||||
})
|
||||
|
||||
it('Cancelacion Apartado mueve el item a "Historico"', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado')
|
||||
expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }])
|
||||
})
|
||||
|
||||
it('las fases permitidas son los labels reales del board Apartado', () => {
|
||||
expect(FASES_APARTADO_PERMITIDAS).toEqual([
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente (Venta)',
|
||||
'Cancelacion Apartado',
|
||||
])
|
||||
})
|
||||
})
|
||||
53
api-gateway/src/controllers/apartados.controller.ts
Normal file
53
api-gateway/src/controllers/apartados.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Request, Response } from 'express'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
||||
* mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
|
||||
* de la columna status 'Estado' del board (verificados en Monday) — el
|
||||
* frontend los manda tal cual.
|
||||
*/
|
||||
export const FASES_APARTADO_PERMITIDAS = [
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente (Venta)',
|
||||
'Cancelacion Apartado',
|
||||
] as const
|
||||
|
||||
const cambiarEstadoSchema = z.object({
|
||||
estado: z.enum(FASES_APARTADO_PERMITIDAS),
|
||||
})
|
||||
|
||||
type MondayApartados = Pick<
|
||||
MondayService,
|
||||
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
|
||||
>
|
||||
|
||||
/**
|
||||
* Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
|
||||
* corresponde (idempotente; el webhook de change haría lo mismo, pero así la
|
||||
* UI responde al instante).
|
||||
*/
|
||||
export async function cambiarEstadoApartado(
|
||||
monday: MondayApartados,
|
||||
itemId: string,
|
||||
estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
|
||||
): Promise<void> {
|
||||
const boardApartado = config.monday.boards.apartado
|
||||
await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
|
||||
await moverAGrupoSegunEstado(monday, boardApartado, itemId)
|
||||
}
|
||||
|
||||
/** POST /api/apartados/:itemId/estado { estado } */
|
||||
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
||||
res.json({ itemId, estado: body.estado })
|
||||
}
|
||||
@@ -111,6 +111,24 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
|
||||
const { res, status, json } = resFake()
|
||||
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
|
||||
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
|
||||
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
|
||||
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
|
||||
expect(opId).toBe('opp-midia-1')
|
||||
expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
|
||||
// La cotización queda vinculada al item del cliente
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
|
||||
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cot-1' },
|
||||
data: { mondayItemId: 'opp-midia-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ const crearCotizacionSchema = z.object({
|
||||
clienteTelefono: z.string().min(7).optional(),
|
||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||
// Item del tablero Oportunidades al que queda vinculada la cotización
|
||||
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
||||
oportunidadId: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
const hoyISO = (): string => {
|
||||
@@ -88,9 +91,15 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
}
|
||||
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
|
||||
// oportunidad (etapa 'Cotizador') en vez de crear una segunda.
|
||||
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
|
||||
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
|
||||
// item directamente y la cotización queda vinculada al cliente.
|
||||
const opExistente =
|
||||
body.clienteTelefono !== undefined ? await funnelService.oportunidadPorTelefono(body.clienteTelefono) : null
|
||||
body.oportunidadId !== undefined
|
||||
? body.oportunidadId
|
||||
: body.clienteTelefono !== undefined
|
||||
? await funnelService.oportunidadPorTelefono(body.clienteTelefono)
|
||||
: null
|
||||
if (opExistente !== null) {
|
||||
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
|
||||
mondayItemId = opExistente
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { perderOportunidad } from './oportunidades.controller.js'
|
||||
import {
|
||||
cambiarEtapaOportunidad,
|
||||
ETAPAS_OPORTUNIDAD_PERMITIDAS,
|
||||
perderOportunidad,
|
||||
} from './oportunidades.controller.js'
|
||||
|
||||
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
||||
function mondayMock() {
|
||||
@@ -50,3 +54,69 @@ describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
|
||||
expect(monday.motivos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */
|
||||
function mondayEtapaMock(grupoActual = 'topics') {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
movimientos: [] as { itemId: string; grupo: string }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
async columnaIdPorTitulo(_boardId: string, titulo: string) {
|
||||
return titulo === 'Estado' ? 'status' : null
|
||||
},
|
||||
async getItemConGrupo(itemId: string) {
|
||||
return {
|
||||
id: itemId,
|
||||
name: 'Cliente Demo',
|
||||
groupId: grupoActual,
|
||||
column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }],
|
||||
}
|
||||
},
|
||||
async moverItemAGrupo(itemId: string, grupo: string) {
|
||||
this.movimientos.push({ itemId, grupo })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
|
||||
it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => {
|
||||
const monday = mondayEtapaMock()
|
||||
await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' },
|
||||
])
|
||||
expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }])
|
||||
})
|
||||
|
||||
it('Agendo Cita setea también la columna Fecha', async () => {
|
||||
const monday = mondayEtapaMock()
|
||||
await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' },
|
||||
{ boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' },
|
||||
])
|
||||
// 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve
|
||||
expect(monday.movimientos).toEqual([])
|
||||
})
|
||||
|
||||
it('sin fecha no toca la columna Fecha', async () => {
|
||||
const monday = mondayEtapaMock()
|
||||
await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' },
|
||||
])
|
||||
})
|
||||
|
||||
it('las etapas permitidas son las fases del funnel definidas por el cliente', () => {
|
||||
expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([
|
||||
'Agendo Cita',
|
||||
'Afluencia',
|
||||
'Cotizador',
|
||||
'Aparto',
|
||||
'No Asistio',
|
||||
'Abandono',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
|
||||
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'
|
||||
|
||||
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
@@ -53,3 +54,61 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
|
||||
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
|
||||
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
|
||||
// ---------- Cambio de etapa desde Mi día ----------
|
||||
|
||||
/**
|
||||
* Etapas del funnel que el asesor puede mover desde los botones de
|
||||
* "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
|
||||
* Oportunidades — el frontend los manda tal cual).
|
||||
*/
|
||||
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
|
||||
'Agendo Cita',
|
||||
'Afluencia',
|
||||
'Cotizador',
|
||||
'Aparto',
|
||||
'No Asistio',
|
||||
'Abandono',
|
||||
] as const
|
||||
|
||||
const cambiarEtapaSchema = z.object({
|
||||
etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
|
||||
// En 'Agendo Cita' puede setear la columna date 'Fecha'
|
||||
fecha: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
type MondayEtapa = Pick<
|
||||
MondayService,
|
||||
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
|
||||
>
|
||||
|
||||
/**
|
||||
* Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
|
||||
* cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
|
||||
* (idempotente; el webhook de change haría lo mismo, pero así la UI responde
|
||||
* al instante).
|
||||
*/
|
||||
export async function cambiarEtapaOportunidad(
|
||||
monday: MondayEtapa,
|
||||
itemId: string,
|
||||
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
|
||||
fecha?: string,
|
||||
): Promise<void> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
|
||||
if (etapa === 'Agendo Cita' && fecha !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
|
||||
}
|
||||
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
|
||||
}
|
||||
|
||||
/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
|
||||
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEtapaSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
|
||||
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user