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

@@ -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 })
}