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