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:
@@ -19,6 +19,7 @@ export const config = {
|
||||
apiKey: env('MONDAY_API_KEY'),
|
||||
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
|
||||
apiVersion: '2024-10',
|
||||
workspace: env('MONDAY_WORKSPACE', ''),
|
||||
boards: {
|
||||
contactos: env('MONDAY_BOARD_CONTACTOS'),
|
||||
desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
|
||||
@@ -27,11 +28,16 @@ export const config = {
|
||||
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
|
||||
apartado: env('MONDAY_BOARD_APARTADO'),
|
||||
ventas: env('MONDAY_BOARD_VENTAS'),
|
||||
// Tableros creados por scripts/setup-crm.ts; '' = aún no configurado
|
||||
motivos: env('MONDAY_BOARD_MOTIVOS', ''),
|
||||
actividades: env('MONDAY_BOARD_ACTIVIDADES', ''),
|
||||
},
|
||||
},
|
||||
automatizaciones: {
|
||||
// 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos
|
||||
enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false',
|
||||
// 'false' desactiva la creación de actividades en Monday (default true; false en tests)
|
||||
actividadesEnabled: env('ACTIVIDADES_ENABLED', 'true') !== 'false',
|
||||
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
|
||||
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
|
||||
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
|
||||
|
||||
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal file
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
actividadEconomicaLabel,
|
||||
checkboxParaTipoDocumento,
|
||||
perfilDemografico,
|
||||
rangoEdadLabel,
|
||||
} from './clientes.controller.js'
|
||||
|
||||
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
|
||||
it('mapea los tipos con checkbox al título de la columna', () => {
|
||||
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
|
||||
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
|
||||
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
|
||||
expect(checkboxParaTipoDocumento('COMPROBANTE')).toBe('Comprobante')
|
||||
expect(checkboxParaTipoDocumento('COMPROBANTE_DOMICILIO')).toBe('Comprobante')
|
||||
})
|
||||
|
||||
it('normaliza mayúsculas y espacios', () => {
|
||||
expect(checkboxParaTipoDocumento(' curp ')).toBe('CURP')
|
||||
expect(checkboxParaTipoDocumento('comprobante_domicilio')).toBe('Comprobante')
|
||||
})
|
||||
|
||||
it('INE y otros tipos no tienen checkbox (null)', () => {
|
||||
expect(checkboxParaTipoDocumento('INE')).toBeNull()
|
||||
expect(checkboxParaTipoDocumento('ACTA_NACIMIENTO')).toBeNull()
|
||||
expect(checkboxParaTipoDocumento('OTRO')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rangoEdadLabel', () => {
|
||||
const hoy = new Date('2026-08-22T12:00:00')
|
||||
|
||||
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
|
||||
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
|
||||
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
|
||||
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
|
||||
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
|
||||
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
|
||||
})
|
||||
|
||||
it('devuelve undefined con fechas inválidas o ausentes', () => {
|
||||
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
|
||||
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
|
||||
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('perfilDemografico (alta → Contacto Monday)', () => {
|
||||
it('mapea los valores del formato a los labels del CRM', () => {
|
||||
const perfil = perfilDemografico({
|
||||
estadoCivil: 'UNION_LIBRE',
|
||||
tipoVivienda: 'FAMILIAR',
|
||||
tipoCompra: 'VIVIR_AHI',
|
||||
fechaNacimiento: '1990-05-10',
|
||||
})
|
||||
expect(perfil.estadoCivilLabel).toBe('Union Libre')
|
||||
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
|
||||
expect(perfil.propositoCompraLabel).toBe('Habitarla')
|
||||
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
|
||||
expect(perfil.fechaNacimiento).toBe('1990-05-10')
|
||||
})
|
||||
|
||||
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
|
||||
expect(perfilDemografico({})).toEqual({})
|
||||
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('actividadEconomicaLabel', () => {
|
||||
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
|
||||
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
|
||||
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
|
||||
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,14 @@ const crearClienteSchema = z
|
||||
email: z.string().optional(),
|
||||
celular: z.string().optional(),
|
||||
direccion: z.string().optional(),
|
||||
// Perfil demográfico (opcionales; se replican al Contacto en Monday)
|
||||
fechaNacimiento: z.string().optional(),
|
||||
estadoCivil: z.string().optional(),
|
||||
tipoVivienda: z.string().optional(),
|
||||
tipoCompra: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(),
|
||||
viviendaCredito: z
|
||||
.object({
|
||||
proyecto: z.string().min(1),
|
||||
@@ -35,6 +41,106 @@ const crearClienteSchema = z
|
||||
})
|
||||
.passthrough() // el resto del payload pasa tal cual a payloadJson
|
||||
|
||||
// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ----------
|
||||
|
||||
const ESTADO_CIVIL_LABEL: Record<string, string> = {
|
||||
SOLTERO: 'Soltero',
|
||||
CASADO: 'Casado',
|
||||
UNION_LIBRE: 'Union Libre',
|
||||
DIVORCIADO: 'Divorciado',
|
||||
VIUDO: 'Viudo',
|
||||
}
|
||||
|
||||
const VIVIENDA_ACTUAL_LABEL: Record<string, string> = {
|
||||
PRESTADA: 'Prestada',
|
||||
RENTADA: 'Rentada',
|
||||
FAMILIAR: 'Comparte con familia',
|
||||
PROPIA: 'Propia',
|
||||
}
|
||||
|
||||
const PROPOSITO_COMPRA_LABEL: Record<string, string> = {
|
||||
INVERSION: 'Inversion',
|
||||
VIVIR_AHI: 'Habitarla',
|
||||
}
|
||||
|
||||
// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un
|
||||
// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina.
|
||||
const ACTIVIDAD_ECONOMICA_LABEL: Record<string, string> = {
|
||||
ASALARIADO: 'Empleado',
|
||||
}
|
||||
|
||||
/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */
|
||||
export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined {
|
||||
if (!fechaNacimiento) return undefined
|
||||
const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`)
|
||||
if (Number.isNaN(nacimiento.getTime())) return undefined
|
||||
let edad = hoy.getFullYear() - nacimiento.getFullYear()
|
||||
const aunNoCumple =
|
||||
hoy.getMonth() < nacimiento.getMonth() ||
|
||||
(hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate())
|
||||
if (aunNoCumple) edad--
|
||||
if (edad < 0 || edad > 120) return undefined
|
||||
if (edad < 24) return 'Menor de 24'
|
||||
if (edad <= 40) return 'Entre 24 y 40'
|
||||
return 'Mayor de 40'
|
||||
}
|
||||
|
||||
/** Labels demográficos del Contacto a partir del payload del formato de alta */
|
||||
export function perfilDemografico(dg: {
|
||||
estadoCivil?: string | undefined
|
||||
tipoVivienda?: string | undefined
|
||||
tipoCompra?: string | undefined
|
||||
fechaNacimiento?: string | undefined
|
||||
}): {
|
||||
estadoCivilLabel?: string | undefined
|
||||
viviendaActualLabel?: string | undefined
|
||||
propositoCompraLabel?: string | undefined
|
||||
rangoEdadLabel?: string | undefined
|
||||
fechaNacimiento?: string | undefined
|
||||
} {
|
||||
const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase()
|
||||
const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '')
|
||||
? dg.fechaNacimiento!.slice(0, 10)
|
||||
: undefined
|
||||
return {
|
||||
...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined
|
||||
? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] }
|
||||
: {}),
|
||||
...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined
|
||||
? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] }
|
||||
: {}),
|
||||
...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined
|
||||
? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] }
|
||||
: {}),
|
||||
...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}),
|
||||
...(fecha !== undefined ? { fechaNacimiento: fecha } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Label de "Actividad Economica" derivado del tipo de empleo del formato */
|
||||
export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined {
|
||||
return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox del tablero Apartado que corresponde a un tipo de documento.
|
||||
* INE y el resto no tienen checkbox (devuelve null).
|
||||
*/
|
||||
export function checkboxParaTipoDocumento(tipo: string): string | null {
|
||||
switch (tipo.trim().toUpperCase()) {
|
||||
case 'CURP':
|
||||
return 'CURP'
|
||||
case 'RFC':
|
||||
case 'CONSTANCIA_RFC':
|
||||
return 'RFC'
|
||||
case 'COMPROBANTE':
|
||||
case 'COMPROBANTE_DOMICILIO':
|
||||
return 'Comprobante'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const aNumero = (v: string | number | undefined): number => {
|
||||
if (v === undefined) return 0
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
|
||||
@@ -81,6 +187,11 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
|
||||
// Perfil demográfico: solo lo que el formato de alta captura.
|
||||
// Sin fuente en el formulario: Sexo (no se pregunta) y Actividad
|
||||
// Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo).
|
||||
...perfilDemografico(dg),
|
||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
||||
})
|
||||
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
@@ -92,7 +203,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'VIGENTE',
|
||||
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,6 +286,23 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Prende el checkbox del documento en el item de Apartado del cliente
|
||||
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida.
|
||||
const checkbox = checkboxParaTipoDocumento(tipo)
|
||||
if (checkbox !== null && cliente.mondayApartadoId !== null) {
|
||||
try {
|
||||
await mondayService.cambiarValorColumna(
|
||||
config.monday.boards.apartado,
|
||||
cliente.mondayApartadoId,
|
||||
checkbox,
|
||||
true,
|
||||
)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
|
||||
}
|
||||
}
|
||||
res.status(201).json(documentos)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
fechaCotizacion: hoyISO(),
|
||||
estadoLabel: 'COTIZADA',
|
||||
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
|
||||
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
})
|
||||
|
||||
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal file
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { perderOportunidad } from './oportunidades.controller.js'
|
||||
|
||||
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
||||
function mondayMock() {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
async obtenerNombreItem(_itemId: string) {
|
||||
return 'Juan Pérez'
|
||||
},
|
||||
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
|
||||
this.motivos.push(datos)
|
||||
return 'motivo-item-1'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
|
||||
it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => {
|
||||
const monday = mondayMock()
|
||||
const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro')
|
||||
|
||||
expect(motivoItemId).toBe('motivo-item-1')
|
||||
// Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test)
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' },
|
||||
{ boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' },
|
||||
])
|
||||
// Item en el catálogo: nombre = motivo + item de origen
|
||||
expect(monday.motivos).toEqual([
|
||||
{ nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('el comentario es opcional', async () => {
|
||||
const monday = mondayMock()
|
||||
await perderOportunidad(monday, 'opp-9', 'Competencia')
|
||||
expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' })
|
||||
})
|
||||
|
||||
it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => {
|
||||
const monday = mondayMock()
|
||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído')
|
||||
// No se registró motivo si falló el cambio de etapa
|
||||
expect(monday.motivos).toEqual([])
|
||||
})
|
||||
})
|
||||
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal file
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
|
||||
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
'Precio',
|
||||
'Producto',
|
||||
'Ubicacion',
|
||||
'Financiamiento',
|
||||
'Competencia',
|
||||
'Cliente',
|
||||
'Timing',
|
||||
] as const
|
||||
|
||||
const perderSchema = z.object({
|
||||
motivo: z.enum(MOTIVOS_NO_COMPRA),
|
||||
comentario: z.string().optional(),
|
||||
})
|
||||
|
||||
type MondayOportunidades = Pick<
|
||||
MondayService,
|
||||
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
|
||||
>
|
||||
|
||||
/**
|
||||
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
|
||||
* registra el motivo en el tablero catálogo Motivos No Compra.
|
||||
* Devuelve el ID del item creado en Motivos No Compra.
|
||||
*/
|
||||
export async function perderOportunidad(
|
||||
monday: MondayOportunidades,
|
||||
mondayItemId: string,
|
||||
motivo: string,
|
||||
comentario?: string,
|
||||
): Promise<string> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
|
||||
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
|
||||
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
|
||||
return monday.crearMotivoNoCompra({
|
||||
nombre: `${motivo} — ${nombreItem}`,
|
||||
categoria: motivo,
|
||||
...(comentario !== undefined ? { descripcion: comentario } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
|
||||
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = perderSchema.parse(req.body)
|
||||
const mondayItemId = String(req.params['mondayItemId'])
|
||||
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
|
||||
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
42
api-gateway/src/controllers/planes.controller.test.ts
Normal file
42
api-gateway/src/controllers/planes.controller.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { notificarEscrituracionSiLiquidado } from './planes.controller.js'
|
||||
|
||||
/** Monday mockeado: registra cambios de columna */
|
||||
function mondayMock() {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
|
||||
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
|
||||
const monday = mondayMock()
|
||||
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
|
||||
expect(ok).toBe(true)
|
||||
// board 666 = MONDAY_BOARD_APARTADO de test
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' },
|
||||
])
|
||||
})
|
||||
|
||||
it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => {
|
||||
const monday = mondayMock()
|
||||
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false)
|
||||
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false)
|
||||
expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false)
|
||||
expect(monday.cambios).toEqual([])
|
||||
})
|
||||
|
||||
it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => {
|
||||
const monday = mondayMock()
|
||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
|
||||
expect(ok).toBe(false)
|
||||
expect(spyError).toHaveBeenCalled()
|
||||
spyError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type PlanPago as PlanPagoDominio,
|
||||
} from '../services/cobranza.service.js'
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
|
||||
|
||||
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
|
||||
@@ -48,6 +50,7 @@ function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
||||
mensualidad: plan.mensualidad,
|
||||
tasaMoraMensual: plan.tasaMoraMensual,
|
||||
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
|
||||
mondayItemId: plan.mondayItemId,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
pagos: plan.pagos
|
||||
.slice()
|
||||
@@ -105,6 +108,8 @@ const crearPlanSchema = z.object({
|
||||
engancheTotal: z.number().positive(),
|
||||
numPagos: z.number().int().min(1),
|
||||
tasaMoraMensual: z.number().min(0).optional(),
|
||||
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
|
||||
mondayItemId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
/** POST /api/planes — genera el calendario de pagos y lo persiste */
|
||||
@@ -129,6 +134,7 @@ export async function crearPlan(req: Request, res: Response): Promise<void> {
|
||||
numPagos: body.numPagos,
|
||||
mensualidad: calendario.mensualidad,
|
||||
tasaMoraMensual: body.tasaMoraMensual ?? 0,
|
||||
mondayItemId: body.mondayItemId ?? null,
|
||||
pagos: {
|
||||
create: calendario.pagos.map((p) => ({
|
||||
numPago: p.numPago,
|
||||
@@ -176,6 +182,29 @@ const abonoSchema = z.object({
|
||||
referencia: z.string().min(1),
|
||||
})
|
||||
|
||||
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
|
||||
|
||||
/**
|
||||
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
|
||||
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
|
||||
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
|
||||
*/
|
||||
export async function notificarEscrituracionSiLiquidado(
|
||||
plan: { mondayItemId?: string | null },
|
||||
liquidado: boolean,
|
||||
monday: MondayApartado = mondayService,
|
||||
): Promise<boolean> {
|
||||
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
|
||||
try {
|
||||
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
|
||||
return true
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
|
||||
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = abonoSchema.parse(req.body)
|
||||
@@ -206,7 +235,24 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
|
||||
])
|
||||
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.status(201).json(conCalculados(aDominio(actualizado)))
|
||||
const dominioActualizado = aDominio(actualizado)
|
||||
const calculado = conCalculados(dominioActualizado)
|
||||
|
||||
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
|
||||
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
|
||||
|
||||
res.status(201).json(calculado)
|
||||
}
|
||||
|
||||
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
|
||||
|
||||
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
|
||||
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
|
||||
const body = ligarMondaySchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
const notaSchema = z.object({ texto: z.string() })
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
cancelarPlan,
|
||||
cobranzaHoy,
|
||||
crearPlan,
|
||||
ligarMondayItem,
|
||||
listarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEndpoint,
|
||||
} from '../controllers/planes.controller.js'
|
||||
import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js'
|
||||
import { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||
import {
|
||||
correrRoundRobin,
|
||||
@@ -60,6 +62,9 @@ apiRouter.get('/planes/:id', obtenerPlan)
|
||||
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||
apiRouter.patch('/planes/:id/monday', ligarMondayItem)
|
||||
|
||||
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||
|
||||
apiRouter.post('/webhooks/monday', webhookMonday)
|
||||
|
||||
|
||||
@@ -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