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

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

View File

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

View File

@@ -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 } : {}),
})

View 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([])
})
})

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

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

View File

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