feat(monday): alinear Frasa-CAS con CRM real del cliente (7 mejoras)

- Vocabulario funnel: labels Intento de contacto/Abandono en Leads,
  etapas completas en Oportunidades (Cotizador, Ganado, Perdido...)
- Apartados: pipeline Enkontrol (8 estados) + checkboxes CURP/RFC/Comprobante
  que la app de alta prende al subir documentos
- Motivos No Compra: board catálogo + dropdown + endpoint /oportunidades/:id/perder
- Perfil demográfico en Contactos mapeado desde el alta (estado civil,
  vivienda, propósito, fecha nac., rango de edad calculado)
- Desarrollos: catálogo maestro sincronizado (precios, m2, tipo, entrega)
- Actividades: board nuevo + creación automática en round-robin e inactividad
- Cobranza: plan liquidado mueve el apartado a 'X Escriturar' en Monday
- Scripts: setup:crm y sync:desarrollos (idempotentes)
- Tests 119/119 verdes
This commit is contained in:
2026-08-22 07:18:48 +00:00
parent afb454b54c
commit f0f923b2ef
22 changed files with 1178 additions and 13 deletions

View File

@@ -43,11 +43,19 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
}
/** Monday mockeado: leads fijos, registra asignaciones y updates */
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[]) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[],
actividades: [] as {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}[],
async getLeads() {
return leads
},
@@ -58,6 +66,17 @@ function mondayMock(leads: LeadMonday[]) {
this.updates.push({ itemId, body })
return 'upd-1'
},
async crearActividad(datos: {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}) {
this.actividades.push(datos)
return 'act-1'
},
async getUsuariosWorkspace() {
return VENDEDORES
},
@@ -238,3 +257,85 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
expect(monday.updates).toHaveLength(2)
})
})
// ---------- Actividades automáticas (tablero Actividades del CRM) ----------
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => {
const monday = mondayMock([lead('1'), lead('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead 1',
tipoLabel: 'Llamada',
responsableId: 101,
estadoLabel: 'Abierto',
leadItemId: '1',
})
// Fecha compromiso: hoy + 1 día (fecha local, como la construye el servicio)
const esperada = new Date()
esperada.setDate(esperada.getDate() + 1)
const m = String(esperada.getMonth() + 1).padStart(2, '0')
const d = String(esperada.getDate()).padStart(2, '0')
expect(monday.actividades[0]?.fecha).toBe(`${esperada.getFullYear()}-${m}-${d}`)
expect(monday.actividades[1]?.responsableId).toBe(102)
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([lead('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
const monday = mondayMock([
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
expect(r.recordatoriosEnviados).toBe(2)
expect(monday.actividades).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Lead 1',
tipoLabel: 'Llamada',
responsableId: 103,
estadoLabel: 'Abierto',
leadItemId: '1',
})
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [101] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([])
})
it('un fallo al crear la actividad no rompe la asignación', async () => {
const monday = mondayMock([lead('1')])
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1) // la asignación se hizo igual
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
spyError.mockRestore()
})
})
describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
it('ABANDONO también es terminal', () => {
expect(esEstadoTerminal('ABANDONO')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})

View File

@@ -13,9 +13,10 @@ import { prisma } from './prisma.js'
* recordatorios el mismo día.
*/
// Labels reales del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO.
// Solo DESCARTADO es terminal (los demás siguen en seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO'] as const
// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO
// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado,
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
const CLAVE_RR_INDICE = 'rr:indice'
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
@@ -85,9 +86,14 @@ export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
type MondayLeads = Pick<
MondayService,
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace'
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' | 'crearActividad'
>
export interface OpcionesAutomatizaciones {
/** Crear actividades en el tablero Actividades al asignar/recordar (default: config) */
actividadesEnabled?: boolean
}
export interface AsignacionRR {
itemId: string
lead: string
@@ -117,10 +123,48 @@ export interface ResultadoInactividad {
}
export class AutomatizacionesService {
private readonly actividadesEnabled: boolean
constructor(
private readonly monday: MondayLeads,
private readonly state: StateStore,
) {}
opciones: OpcionesAutomatizaciones = {},
) {
this.actividadesEnabled = opciones.actividadesEnabled ?? config.automatizaciones.actividadesEnabled
}
/** 'YYYY-MM-DD' de hoy + N días (fecha compromiso de la actividad) */
private static fechaHoyMas(dias: number): string {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/**
* Crea una actividad en Monday sin interrumpir la automatización:
* un fallo aquí solo se loguea (la asignación/recordatorio ya se hizo).
*/
private async crearActividadSegura(datos: {
nombre: string
responsableId: number
leadItemId: string
}): Promise<void> {
try {
await this.monday.crearActividad({
nombre: datos.nombre,
tipoLabel: 'Llamada',
responsableId: datos.responsableId,
fecha: AutomatizacionesService.fechaHoyMas(1),
estadoLabel: 'Abierto',
leadItemId: datos.leadItemId,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la actividad "${datos.nombre}":`, err)
}
}
/**
* Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida;
@@ -167,6 +211,13 @@ export class AutomatizacionesService {
const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
await this.monday.asignarVendedorALead(lead.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${lead.nombre}`,
responsableId: vendedor.id,
leadItemId: lead.id,
})
}
detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
indice++
}
@@ -208,6 +259,15 @@ export class AutomatizacionesService {
await this.monday.crearUpdateEnItem(lead.id, body)
await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++
// Además del update, deja una actividad al vendedor asignado (si tiene)
const vendedorId = lead.vendedorIds[0]
if (this.actividadesEnabled && vendedorId !== undefined) {
await this.crearActividadSegura({
nombre: `Retomar contacto con ${lead.nombre}`,
responsableId: vendedorId,
leadItemId: lead.id,
})
}
}
if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString())

View File

@@ -44,6 +44,8 @@ export interface PlanPago {
mensualidad: number
tasaMoraMensual?: number
status: StatusPlan
/** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */
mondayItemId?: string | null
pagos: Pago[]
notas: NotaPlan[]
createdAt: string // ISO

View File

@@ -279,10 +279,12 @@ export class MondayService {
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query).
*/
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
private valorPorTipo(col: ColumnaMeta, crudo: string | number | boolean): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'dropdown':
return { labels: [String(crudo)] }
case 'date':
return { date: String(crudo) }
case 'numbers':
@@ -291,6 +293,14 @@ export class MondayService {
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
case 'checkbox':
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
case 'people':
return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] }
case 'board_relation':
return { item_ids: [Number(crudo)] }
case 'long_text':
return { text: String(crudo) }
default:
return String(crudo)
}
@@ -298,7 +308,7 @@ export class MondayService {
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | undefined>,
valores: Record<string, string | number | boolean | undefined>,
): Promise<string> {
const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) {
@@ -346,6 +356,14 @@ export class MondayService {
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
// Perfil demográfico (vocabulario CRM FRASA); solo se envía lo capturado
Sexo: datos.sexoLabel,
'Estado Civil': datos.estadoCivilLabel,
'Rango de Edad': datos.rangoEdadLabel,
'Actividad Economica': datos.actividadEconomicaLabel,
'Vivienda Actual': datos.viviendaActualLabel,
'Proposito de Compra': datos.propositoCompraLabel,
'Fecha Nacimiento': datos.fechaNacimiento,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
@@ -378,6 +396,74 @@ export class MondayService {
this.invalidarCacheUnidades()
}
// ---------- Escrituras genéricas ----------
/**
* Cambia UNA columna de un item, resolviendo el column ID por título y
* serializando según el tipo de columna (status, dropdown, checkbox, date...).
*/
async cambiarValorColumna(
boardId: string,
itemId: string,
tituloColumna: string,
valor: string | number | boolean,
): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, valor)) },
)
}
/** Nombre de un item por ID (cualquier tablero) */
async obtenerNombreItem(itemId: string): Promise<string> {
const data = await this.graphql<{ items: { name: string }[] }>(
`query ($ids: [ID!]) { items(ids: $ids) { name } }`,
{ ids: [itemId] },
)
const nombre = data.items[0]?.name
if (nombre === undefined) throw new AppError(404, `Item ${itemId} no encontrado en Monday`)
return nombre
}
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
const boardId = config.monday.boards.motivos
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
const columnValues = await this.construirColumnValues(boardId, {
Categoria: datos.categoria,
Descripcion: datos.descripcion,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/** Actividad de seguimiento ligada a un lead (bitácora del CRM) */
async crearActividad(datos: DatosActividad): Promise<string> {
const boardId = config.monday.boards.actividades
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_ACTIVIDADES no está configurado')
// La columna board_relation "Lead" no puede crearse vía API (limitación de
// Monday): si existe en el tablero se liga, si no, la actividad se crea igual.
let lead: string | undefined
try {
await this.columnaId(boardId, 'Lead')
lead = datos.leadItemId
} catch {
lead = undefined
}
const columnValues = await this.construirColumnValues(boardId, {
Tipo: datos.tipoLabel,
Responsable: datos.responsableId,
Fecha: datos.fecha,
Estado: datos.estadoLabel,
Lead: lead,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
// ---------- Leads (automatizaciones) ----------
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
@@ -510,6 +596,14 @@ export interface DatosContacto {
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
// Perfil demográfico (labels del dropdown correspondiente)
sexoLabel?: string | undefined
estadoCivilLabel?: string | undefined
rangoEdadLabel?: string | undefined
actividadEconomicaLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
fechaNacimiento?: string | undefined // 'YYYY-MM-DD'
}
export interface DatosApartado {
@@ -518,7 +612,22 @@ export interface DatosApartado {
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE'
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA
descripcion?: string
}
export interface DatosActividad {
nombre: string
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
responsableId: number // user ID de Monday
fecha: string // 'YYYY-MM-DD'
estadoLabel: string // 'Abierto' | 'Listo'
leadItemId: string // item del tablero Leads relacionado
}
/** Singleton usado por los controllers (los tests instancian el suyo) */