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