feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -5,13 +5,8 @@ import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type DatosApartado, type DatosContacto } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { crmLocal } from '../services/crm-local.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
@@ -27,7 +22,7 @@ export 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)
// Perfil demográfico (opcionales; viajan en el payload del alta)
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
@@ -48,90 +43,9 @@ export 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).
* Checkbox que corresponde a un tipo de documento en el expediente del
* apartado. INE y el resto no tienen checkbox (devuelve null).
*/
export function checkboxParaTipoDocumento(tipo: string): string | null {
switch (tipo.trim().toUpperCase()) {
@@ -162,33 +76,7 @@ const hoyMas = (dias: number): string => {
return `${d.getFullYear()}-${m}-${dia}`
}
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico del
* tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.
*/
async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<void> {
const leadMondayId = body['leadMondayId']
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
try {
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
// El worker lo reintenta cuando Monday vuelva (con el mismo dedup)
await encolarSyncSeguro('APARTO_OPORTUNIDAD', { leadId: leadMondayId }, leadMondayId)
}
}
}
/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */
/** Label de "Forma de Pago" a partir del enum del formato de alta */
const FORMA_PAGO_LABEL: Record<string, string> = {
INFONAVIT: 'Infonavit',
APOYO_INFONAVIT: 'Apoyo Infonavit',
@@ -201,9 +89,8 @@ const FORMA_PAGO_LABEL: Record<string, string> = {
}
/**
* Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo
* del wizard; se toma el PRIMERO reconocido — la columna del board es de una
* sola forma de pago). undefined si no hay ninguno reconocible.
* "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del
* wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno.
*/
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
if (!Array.isArray(tiposCredito)) return undefined
@@ -214,80 +101,54 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined {
return undefined
}
// ---------- Helpers locales compartidos (alta directa y alta progresiva) ----------
const apartadoLigadoDeBody = (body: Record<string, unknown>): string | null => {
const id = body['apartadoMondayId']
return typeof id === 'string' && id !== '' ? id : null
}
/**
* Datos del item en el tablero Apartado (pipeline Enkontrol) para el alta;
* null si no hay valor de venta (no aplica apartado). Lo usan tanto la réplica
* directa (crearApartadoSiAplica) como el encolado diferido (CREAR_APARTADO).
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se
* crea el apartado local (crmLocal.apartar; idempotente por dedup). El id
* puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se
* resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup.
* Devuelve el id de la fila de apartado local creada (null si no aplica).
* Un fallo aquí no rompe el alta.
*/
async function datosApartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
async function moverLeadAApartoSiAplica(
body: Record<string, unknown>,
capturadoPorId: string | null,
): Promise<DatosApartado | null> {
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta <= 0) return null
const enganche = aNumero(vc.enganche)
// Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta
let vendedorId: number | undefined
if (capturadoPorId !== null) {
const capturista = await prisma.usuario.findUnique({
where: { id: capturadoPorId },
select: { mondayUserId: true },
): Promise<string | null> {
const leadId = body['leadMondayId']
if (typeof leadId !== 'string' || leadId === '') return null
try {
const comoOportunidad = await prisma.leadOportunidad.findUnique({
where: { oportunidadId: leadId },
})
vendedorId = capturista?.mondayUserId ?? undefined
const mapping =
comoOportunidad ??
(await prisma.leadOportunidad.findUnique({ where: { leadId } }))
const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null
if (oportunidadId === null) return null
const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined)
return apartado.leadId
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err)
return null
}
return {
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
telefono: dg.celular,
email: dg.email,
curp: dg.curp,
precioFinal: valorVenta,
enganche: enganche > 0 ? enganche : undefined,
fechaCompra: hoyMas(0),
vendedorId,
formaPagoLabel: formaPagoLabel(vc.tiposCredito),
}
}
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
async function crearApartadoSiAplica(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
): Promise<string | null> {
const datos = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datos === null) return null
return mondayService.crearApartado(datos)
}
/**
* Réplica diferida del alta (modo local): cuando la réplica Monday falla a
* media operación, encola en SyncPendiente solo lo que no alcanzó a crearse
* (Contacto y/o Apartado) para que el worker lo aplique cuando Monday vuelva.
* Si el alta venía ligada a un apartado Monday existente (apartadoMondayId),
* no se encola la creación: el item ya existe (solo falló enriquecerlo).
* Devuelve el mondayApartadoId efectivo tras el fallback.
* Enriquece la fila de apartado local con lo capturado en el alta (unidad,
* precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un
* apartado encuentra así la ficha completa al prellenar. Best-effort.
*/
async function encolarReplicasPendientesCliente(
clienteId: string,
datosContacto: DatosContacto,
mondayContactoId: string | null,
mondayApartadoId: string | null,
async function enriquecerApartadoLocal(
apartadoId: string,
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
@@ -295,57 +156,60 @@ async function encolarReplicasPendientesCliente(
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
if (mondayContactoId === null) {
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId)
): Promise<void> {
try {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } })
if (!captura) return
let datos: Record<string, unknown> = {}
if (captura.datosJson) {
try { datos = JSON.parse(captura.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? ''
if (valorVenta > 0) datos['precioFinal'] = valorVenta
if (enganche > 0) {
datos['enganche'] = enganche
datos['montoApartado'] = enganche
}
datos['estadoLabel'] = 'Pago Apartado'
datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0)
datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15)
datos['fechaCompra'] = hoyMas(0)
const formaPago = formaPagoLabel(vc.tiposCredito)
if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago
if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular
if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email
if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp
await prisma.leadCaptura.update({
where: { leadId: apartadoId },
data: {
datosJson: JSON.stringify(datos),
...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}),
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err)
}
if (mondayApartadoId !== null) return mondayApartadoId
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente !== null) return existente
const datosApartado = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datosApartado !== null) {
await encolarSyncSeguro('CREAR_APARTADO', { datos: datosApartado, clienteId }, clienteId)
}
return null
}
/**
* Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un
* apartado existente, botón "Alta Enkontrol" de Mis apartados) se
* LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre.
* Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva
* el nombre histórico: es el id de la fila de apartado, local o heredado).
* Best-effort: un fallo no rompe el alta.
*/
async function apartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId)
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
// Solo se mandan los campos con valor (exactOptionalPropertyTypes)
await mondayService.actualizarApartado(existente, {
clienteNombre: dg.nombreCompleto,
...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}),
...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}),
...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}),
...(valorVenta > 0 ? { precioFinal: valorVenta } : {}),
...(enganche > 0 ? { enganche } : {}),
...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}),
})
return existente
async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise<void> {
try {
await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err)
}
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
@@ -366,62 +230,22 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
let mondayApartadoId: string | null = null
const datosContacto: DatosContacto = {
nombre: dg.nombreCompleto,
estadoLabel: 'CLIENTE',
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
...(dg.email !== undefined ? { email: dg.email } : {}),
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) },
}
try {
mondayContactoId = await mondayService.crearContacto(datosContacto)
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida de lo que faltó y conserva los ids que sí se crearon
mondayApartadoId = await encolarReplicasPendientesCliente(
cliente.id,
datosContacto,
mondayContactoId,
mondayApartadoId,
dg,
vc,
req.usuarioToken?.sub ?? null,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis
// apartados) se LIGA tal cual; si el alta viene del funnel se mueve la
// oportunidad a 'Aparto' y se crea/enriquece el apartado local.
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, req.usuarioToken?.sub ?? null)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(cliente.id, mondayApartadoId)
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
}
@@ -429,7 +253,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
/**
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
* opcionales porque se completan en los pasos siguientes.
*/
export const iniciarProgresoSchema = z
.object({
@@ -455,62 +279,7 @@ export const iniciarProgresoSchema = z
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
const actualizarProgresoSchema = z.object({}).passthrough()
const seccionDe = (datos: Record<string, unknown>, clave: string): Record<string, unknown> => {
const v = datos[clave]
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
}
const textoDe = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v : undefined
/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */
export function textoDireccion(dom: Record<string, unknown>): string | undefined {
const t = (k: string): string => textoDe(dom[k])?.trim() ?? ''
const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ')
const numInt = t('numInt')
const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ')
const cp = t('cp')
const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter(
(p) => p !== '',
)
return partes.length > 0 ? partes.join(', ') : undefined
}
/**
* Deriva TODOS los campos mapeables del Contacto de Monday presentes en el
* estado del wizard (idempotente: se manda el estado completo en cada avance).
*/
export function contactoDesdeDatos(datos: Record<string, unknown>): Omit<DatosContacto, 'estadoLabel'> {
const dg = seccionDe(datos, 'datosGenerales')
const empleo = seccionDe(datos, 'empleo')
const vc = seccionDe(datos, 'viviendaCredito')
// El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda
const domRaiz = seccionDe(datos, 'domicilio')
const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal')
const curp = textoDe(dg['curp'])
const rfc = textoDe(dg['rfc'])
const email = textoDe(dg['email'])
const telefono = textoDe(dg['celular'])
const direccion = textoDireccion(dom) ?? textoDe(dg['direccion'])
const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo']))
return {
nombre: textoDe(dg['nombreCompleto']) ?? '',
...(curp !== undefined ? { curp } : {}),
...(rfc !== undefined ? { rfc } : {}),
...(email !== undefined ? { email } : {}),
...(telefono !== undefined ? { telefono } : {}),
...(direccion !== undefined ? { direccion } : {}),
...perfilDemografico({
estadoCivil: textoDe(dg['estadoCivil']),
tipoVivienda: textoDe(dg['tipoVivienda']),
tipoCompra: textoDe(dg['tipoCompra']),
fechaNacimiento: textoDe(dg['fechaNacimiento']),
}),
...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}),
}
}
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso */
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
const body = iniciarProgresoSchema.parse(req.body)
const dg = body.datosGenerales
@@ -528,26 +297,10 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
},
})
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó y la
// réplica queda encolada (CREAR_CONTACTO) para cuando Monday vuelva
let mondayContactoId: string | null = null
const datosContactoProspecto: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'PROSPECTO',
}
try {
mondayContactoId = await mondayService.crearContacto(datosContactoProspecto)
await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err)
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContactoProspecto }, cliente.id)
}
res.status(201).json({ id: cliente.id, mondayContactoId })
res.status(201).json({ id: cliente.id, mondayContactoId: null })
}
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
/** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = actualizarProgresoSchema.parse(req.body)
@@ -556,21 +309,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
// Réplica idempotente en Monday; un fallo no rompe el avance del wizard
// (queda encolada como ACTUALIZAR_CONTACTO para reintentar al volver Monday)
let mondaySync = false
if (cliente.mondayContactoId !== null) {
try {
await mondayService.actualizarContacto(cliente.mondayContactoId, contactoDesdeDatos(body))
mondaySync = true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo actualizar el Contacto ${cliente.mondayContactoId} en Monday:`, err)
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: contactoDesdeDatos(body) }, id)
}
}
res.json({ id, mondaySync })
res.json({ id, mondaySync: false })
}
/**
@@ -600,66 +339,20 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los
// datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa
let mondayContactoId: string | null = cliente.mondayContactoId
let mondayApartadoId: string | null = cliente.mondayApartadoId
const datosContactoCliente: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'CLIENTE',
}
try {
if (mondayContactoId !== null) {
await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente)
} else {
mondayContactoId = await mondayService.crearContacto(datosContactoCliente)
}
if (mondayApartadoId === null) {
// Con apartadoMondayId (alta desde un apartado existente) se liga y
// enriquece ese item; si no, se crea. El capturista original del alta
// progresiva es quien queda como Vendedor.
mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId)
}
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida: actualización del Contacto si ya existía
// (es idempotente) o su creación; y el Apartado si no se creó
if (mondayContactoId !== null) {
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: datosContactoCliente }, id)
}
mondayApartadoId = await encolarReplicasPendientesCliente(
id,
datosContactoCliente,
mondayContactoId,
mondayApartadoId,
dg,
vc,
cliente.capturadoPorId,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta (misma lógica que el alta directa)
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, cliente.capturadoPorId)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(id, mondayApartadoId)
}
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId })
}
/** GET /api/clientes/:id */
@@ -729,29 +422,6 @@ 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: queda
// encolado (CHECKBOX_DOC) para que el worker lo aplique al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
if (esIdLocal(cliente.mondayApartadoId)) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
} else {
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)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
}
}
}
res.status(201).json(documentos)
}
@@ -769,81 +439,18 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
/**
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
* apartado de Monday para prellenar el alta de cliente (botón "Alta Enkontrol"
* de Mis apartados): ficha del apartado, contacto relacionado
* (perfil completo), oportunidad relacionada (perfil de la afluencia) y la
* cotización más reciente vinculada. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email.
* apartado para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis
* apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto
* relacionado (ficha del Cliente), oportunidad relacionada (perfil de la
* afluencia) y la cotización más reciente vinculada. Las relaciones se arman
* por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
// Monday caído (circuit breaker): prefill 100% local
if (!saludMonday.estaDisponible()) {
res.json(await prefillApartadoLocal(itemId))
return
}
try {
const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId)
if (!apartado) throw new NotFoundError('Apartado no encontrado')
const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10)
const email = (apartado.email ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) ||
(email !== '' && e.trim().toLowerCase() === email)
const [contactos, oportunidades] = await Promise.all([
mondayService.getContactos(),
mondayService.getOportunidades(),
])
const contactoBase = contactos.find((c) => coincide(c.telefono, c.email))
const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null
const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null
// Cotización local más reciente: vinculada a la oportunidad o por teléfono
let cotizacion = null
if (oportunidad) {
cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: oportunidad.id },
orderBy: { createdAt: 'desc' },
})
}
if (!cotizacion && tel !== '') {
cotizacion = await prisma.cotizacion.findFirst({
where: { clienteTelefono: { contains: tel } },
orderBy: { createdAt: 'desc' },
})
}
res.json({
apartado,
contacto,
oportunidad,
cotizacion: cotizacion
? {
tipoPropuesta: cotizacion.tipoPropuesta,
acabados: cotizacion.acabados,
apartado: cotizacion.apartado,
precioLista: cotizacion.precioLista,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
}
: null,
})
} catch (err) {
// Monday se cayó a media lectura: prefill local (el 404 de "apartado no
// encontrado" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
res.json(await prefillApartadoLocal(itemId))
}
res.json(await prefillApartadoLocal(itemId))
}
/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */
/** Shape de la cotización en la respuesta del prefill */
function cotizacionPrefill(cotizacion: {
tipoPropuesta: string
acabados: boolean
@@ -869,11 +476,10 @@ function cotizacionPrefill(cotizacion: {
}
/**
* Prefill local (Monday caído): el apartado es la fila LeadCaptura board=
* APARTADO (id loc_* o sombra); el contacto sale del Cliente ligado, la
* oportunidad de la captura board=OPORTUNIDADES con el mismo teléfono (su
* perfil va en datosJson) y la cotización de la tabla local. Mismo contrato
* que el camino Monday. 404 si no existe el apartado local.
* Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o
* sombra); el contacto sale del Cliente ligado, la oportunidad de la captura
* board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la
* cotización de la tabla local.
*/
async function prefillApartadoLocal(itemId: string) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })