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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user