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