feat: Interesado en leads, lada MX/USA, orígenes FRASA y subcategoría de descarte

- Mis leads: botón Interesado (label sembrado en Monday, mueve a Leads
  Progreso); el botón Agenda cita ahora solo vive en Mis oportunidades
- Descarte: además de la categoría, subcategoría del catálogo de Monday
  (workspace de referencia CRM FRASA); se guarda en la columna
  Subcategoria del tablero Motivos No Compra y el item queda en el grupo
  de su categoría
- Teléfonos con selector de lada 🇲🇽 +52 / 🇺🇸 +1 en captura rápida (Mi
  día), Alta Afluencia y Alta de Cliente (tel. casa y celular); Monday
  escribe countryShortName MX/US y el número compacto (rechaza espacios);
  el dedup sigue comparando por los últimos 10 dígitos
- Origen nuevo en captura rápida y afluencia: PASE, PROSPECCION ASESOR,
  ACTIVACION, FACEBOOK FRASA, REFERIDO, OTRO (sembrados en Leads.Origen y
  Oportunidades.Origen del Lead)
- Tests: 461 API + 16 mi-dia + 116 alta-clientes, verde
This commit is contained in:
2026-09-04 23:28:56 +00:00
parent 5d7c6f58cd
commit 27ba9ea8fa
14 changed files with 279 additions and 72 deletions

View File

@@ -308,7 +308,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'])
// El descarte tiene su propio endpoint (lleva motivo)
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
@@ -329,6 +329,20 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
])
})
it('INTERESADO escribe el label en el tablero Leads (444)', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'INTERESADO', undefined, 'lead')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Interesado' }])
})
it('INTERESADO no aplica al tablero Oportunidades (no es etapa del funnel)', async () => {
const monday = mondayMock()
await expect(cambiarEstadoLead(monday, 'op-1', 'INTERESADO', undefined, 'oportunidad')).rejects.toThrow(
/Interesado solo aplica a leads/,
)
expect(monday.cambios).toEqual([])
})
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
@@ -401,6 +415,20 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('con subcategoría: va en el nombre y en la columna Subcategoria del catálogo', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Precio', undefined, 'lead', 'Precio Total')
expect(monday.motivos).toEqual([
{ nombre: 'Precio / Precio Total — Lead Prueba', categoria: 'Precio', subcategoria: 'Precio Total' },
])
})
it('sin subcategoría el nombre del catálogo queda como antes', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead')
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))

View File

@@ -5,7 +5,7 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
@@ -23,19 +23,21 @@ import { AppError } from '../utils/errors.js'
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado',
'AGENDA CITA': 'Agenda Cita',
CALIFICADO: 'Calificado',
}
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
@@ -93,9 +95,14 @@ const cambiarEstadoSchema = z.object({
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
// Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida)
subcategoria: z.string().trim().min(1).optional(),
comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema,
})
}).refine(
(b) => b.subcategoria === undefined || SUBCATEGORIAS_NO_COMPRA[b.motivo].includes(b.subcategoria),
{ message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] },
)
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
@@ -333,6 +340,10 @@ export async function cambiarEstadoLead(
fuente: FuenteLead = 'oportunidad',
): Promise<void> {
const esLead = fuente === 'lead'
// 'Interesado' solo existe en el tablero Leads (no es etapa del funnel)
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
@@ -346,7 +357,8 @@ export async function cambiarEstadoLead(
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo en el tablero catálogo Motivos No Compra.
* Ambos registran el motivo (y la subcategoría si viene) en el tablero
* catálogo Motivos No Compra.
*/
export async function descartarLead(
monday: MondayLeads,
@@ -354,6 +366,7 @@ export async function descartarLead(
motivo: string,
comentario?: string,
fuente: FuenteLead = 'oportunidad',
subcategoria?: string,
): Promise<string> {
const nombreItem = await monday.obtenerNombreItem(itemId)
if (fuente === 'lead') {
@@ -377,8 +390,9 @@ export async function descartarLead(
}
}
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
@@ -513,11 +527,11 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */
/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente)
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
}

View File

@@ -15,6 +15,23 @@ export const MOTIVOS_NO_COMPRA = [
'Timing',
] as const
/**
* Subcategorías por categoría, tomadas del tablero de referencia "Motivos No
* Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo).
* Sembradas como labels de la columna status 'Subcategoria' del tablero
* Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes'
* llevan las faltas de ortografía originales del tablero del cliente.
*/
export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(),

View File

@@ -24,6 +24,7 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
NUEVO: 'topics',
'Intento de contacto': 'group_mm6s1jct',
CONTACTADO: 'group_mm6s1jct',
Interesado: 'group_mm6s1jct',
'AGENDA CITA': 'group_mm6s1jct',
CALIFICADO: 'group_mm6s1jct',
DESCARTADO: 'group_mm6s96mc',

View File

@@ -415,8 +415,14 @@ export class MondayService {
return typeof crudo === 'number' ? crudo : Number(crudo)
case 'email':
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
case 'phone': {
// LADA explícita: '+1...' (o 11 dígitos empezando en 1) = USA; el resto = México.
// Monday rechaza el número con espacios ("+52 664..."): se envía compacto.
const tel = String(crudo).replace(/\s+/g, '')
const digitos = tel.replace(/\D/g, '')
const esUsa = tel.startsWith('+1') || (digitos.length === 11 && digitos.startsWith('1'))
return { phone: tel, countryShortName: esUsa ? 'US' : 'MX' }
}
case 'checkbox':
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
case 'people':
@@ -751,6 +757,7 @@ export class MondayService {
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
const columnValues = await this.construirColumnValues(boardId, {
Categoria: datos.categoria,
Subcategoria: datos.subcategoria,
Descripcion: datos.descripcion,
})
const buscada = datos.categoria.trim().toUpperCase()
@@ -1501,6 +1508,8 @@ export interface DatosVenta {
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA
/** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */
subcategoria?: string
descripcion?: string
}