- Firma Cliente (Venta) renombrada a Alta Enkontrol (mismo redirect a alta cliente) - Campo 'Usuario Enkontrol' añadido en Datos Generales del alta cliente - Unidad se bloquea (APARTADA) al pasar cliente a etapa Aparto - Backend y frontend actualizados
955 lines
36 KiB
TypeScript
955 lines
36 KiB
TypeScript
import type { Request, Response } from 'express'
|
|
import path from 'node:path'
|
|
import fs from 'node:fs'
|
|
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 { NotFoundError, BadRequestError } from '../utils/errors.js'
|
|
import * as auditoria from '../services/auditoria.service.js'
|
|
|
|
// ---------- Alta de clientes ----------
|
|
|
|
export const crearClienteSchema = z
|
|
.object({
|
|
datosGenerales: z
|
|
.object({
|
|
nombreCompleto: z.string().min(1),
|
|
curp: z.string().min(1),
|
|
rfc: z.string().min(1),
|
|
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(),
|
|
usuarioEnkontrol: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(),
|
|
viviendaCredito: z
|
|
.object({
|
|
proyecto: z.string().min(1),
|
|
ubicacion: z.string().optional(),
|
|
valorVenta: z.union([z.string(), z.number()]).optional(),
|
|
enganche: z.union([z.string(), z.number()]).optional(),
|
|
})
|
|
.passthrough(),
|
|
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
|
|
})
|
|
.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, ''))
|
|
return Number.isFinite(n) ? n : 0
|
|
}
|
|
|
|
const hoyMas = (dias: number): string => {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + dias)
|
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
const dia = String(d.getDate()).padStart(2, '0')
|
|
return `${d.getFullYear()}-${m}-${dia}`
|
|
}
|
|
|
|
// ---------- 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 */
|
|
const FORMA_PAGO_LABEL: Record<string, string> = {
|
|
INFONAVIT: 'Infonavit',
|
|
APOYO_INFONAVIT: 'Apoyo Infonavit',
|
|
COFINAVIT: 'Cofinavit',
|
|
SEGUNDO_CREDITO: 'Segundo crédito',
|
|
CONTADO: 'Contado',
|
|
FOVISSSTE: 'Fovissste',
|
|
COFI_FOVISSSTE: 'Cofi-Fovissste',
|
|
BANCARIO: 'Bancario',
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
|
|
if (!Array.isArray(tiposCredito)) return undefined
|
|
for (const t of tiposCredito) {
|
|
const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()]
|
|
if (label !== undefined) return label
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
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
|
|
},
|
|
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 },
|
|
})
|
|
vendedorId = capturista?.mondayUserId ?? undefined
|
|
}
|
|
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.
|
|
*/
|
|
async function encolarReplicasPendientesCliente(
|
|
clienteId: string,
|
|
datosContacto: DatosContacto,
|
|
mondayContactoId: string | null,
|
|
mondayApartadoId: string | null,
|
|
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> {
|
|
if (mondayContactoId === null) {
|
|
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId)
|
|
}
|
|
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.
|
|
*/
|
|
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
|
|
}
|
|
|
|
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
|
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|
const body = crearClienteSchema.parse(req.body)
|
|
const dg = body.datosGenerales
|
|
const vc = body.viviendaCredito
|
|
|
|
const cliente = await prisma.cliente.create({
|
|
data: {
|
|
nombreCompleto: dg.nombreCompleto,
|
|
curp: dg.curp,
|
|
rfc: dg.rfc,
|
|
email: dg.email ?? null,
|
|
celular: dg.celular ?? null,
|
|
proyecto: vc.proyecto,
|
|
unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
|
|
capturadoPorId: req.usuarioToken?.sub ?? null,
|
|
payloadJson: JSON.stringify(body),
|
|
firmaPng: body.firma?.firmaDataUrl ?? null,
|
|
},
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
|
|
}
|
|
|
|
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------
|
|
|
|
/**
|
|
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
|
|
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
|
|
*/
|
|
export const iniciarProgresoSchema = z
|
|
.object({
|
|
datosGenerales: z
|
|
.object({
|
|
nombreCompleto: z.string().min(1),
|
|
curp: z.string().optional(),
|
|
rfc: z.string().optional(),
|
|
email: z.string().optional(),
|
|
celular: z.string().optional(),
|
|
direccion: z.string().optional(),
|
|
fechaNacimiento: z.string().optional(),
|
|
estadoCivil: z.string().optional(),
|
|
tipoVivienda: z.string().optional(),
|
|
tipoCompra: z.string().optional(),
|
|
usuarioEnkontrol: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
leadMondayId: z.string().optional(),
|
|
})
|
|
.passthrough()
|
|
|
|
/** 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 */
|
|
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
|
|
const body = iniciarProgresoSchema.parse(req.body)
|
|
const dg = body.datosGenerales
|
|
|
|
const cliente = await prisma.cliente.create({
|
|
data: {
|
|
nombreCompleto: dg.nombreCompleto,
|
|
curp: dg.curp ?? '',
|
|
rfc: dg.rfc ?? '',
|
|
email: dg.email ?? null,
|
|
celular: dg.celular ?? null,
|
|
proyecto: '', // se define al completar el alta
|
|
capturadoPorId: req.usuarioToken?.sub ?? null,
|
|
payloadJson: JSON.stringify(body),
|
|
},
|
|
})
|
|
|
|
// 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 })
|
|
}
|
|
|
|
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
|
|
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
|
|
const id = String(req.params['id'])
|
|
const body = actualizarProgresoSchema.parse(req.body)
|
|
const cliente = await prisma.cliente.findUnique({ where: { id } })
|
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
|
|
|
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 })
|
|
}
|
|
|
|
/**
|
|
* POST /api/clientes/progreso/:id/completar — cierra el alta iniciada con el
|
|
* guardado progresivo. Misma validación y shape de respuesta que POST /clientes.
|
|
*/
|
|
export async function completarProgreso(req: Request, res: Response): Promise<void> {
|
|
const id = String(req.params['id'])
|
|
const body = crearClienteSchema.parse(req.body)
|
|
const dg = body.datosGenerales
|
|
const vc = body.viviendaCredito
|
|
const cliente = await prisma.cliente.findUnique({ where: { id } })
|
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
|
|
|
const actualizado = await prisma.cliente.update({
|
|
where: { id },
|
|
data: {
|
|
nombreCompleto: dg.nombreCompleto,
|
|
curp: dg.curp,
|
|
rfc: dg.rfc,
|
|
email: dg.email ?? null,
|
|
celular: dg.celular ?? null,
|
|
proyecto: vc.proyecto,
|
|
unidadCodigo: vc.ubicacion ?? '',
|
|
payloadJson: JSON.stringify(body),
|
|
firmaPng: body.firma?.firmaDataUrl ?? null,
|
|
},
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
|
|
}
|
|
|
|
/** GET /api/clientes/:id */
|
|
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
|
|
const cliente = await prisma.cliente.findUnique({
|
|
where: { id: String(req.params['id']) },
|
|
include: { documentos: true },
|
|
})
|
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
|
res.json(cliente)
|
|
}
|
|
|
|
// ---------- Documentos ----------
|
|
|
|
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
|
const EXT_POR_MIME: Record<string, string> = {
|
|
'application/pdf': 'pdf',
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (req, _file, cb) => {
|
|
const dir = path.join(config.uploadsDir, String(req.params['id']))
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
cb(null, dir)
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
|
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
|
|
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
|
},
|
|
})
|
|
|
|
export const uploadDocumentos = multer({
|
|
storage,
|
|
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
|
fileFilter: (_req, file, cb) => {
|
|
if (!MIME_PERMITIDOS.has(file.mimetype)) {
|
|
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
|
|
return
|
|
}
|
|
cb(null, true)
|
|
},
|
|
})
|
|
|
|
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
|
|
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
|
|
const clienteId = String(req.params['id'])
|
|
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
|
|
|
const archivos = (req.files ?? []) as Express.Multer.File[]
|
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
|
const documentos = await Promise.all(
|
|
archivos.map((f) =>
|
|
prisma.documento.create({
|
|
data: {
|
|
clienteId,
|
|
tipo,
|
|
nombreArchivo: f.originalname,
|
|
ruta: path.relative(config.uploadsDir, f.path),
|
|
mime: f.mimetype,
|
|
tamano: f.size,
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
/** GET /api/clientes/:id/documentos */
|
|
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
|
|
const clienteId = String(req.params['id'])
|
|
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
|
const documentos = await prisma.documento.findMany({
|
|
where: { clienteId },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
res.json(documentos)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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))
|
|
}
|
|
}
|
|
|
|
/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */
|
|
function cotizacionPrefill(cotizacion: {
|
|
tipoPropuesta: string
|
|
acabados: boolean
|
|
apartado: number
|
|
precioLista: number
|
|
precioFinal: number
|
|
enganche: number
|
|
numMensualidades: number
|
|
mensualidad: number | null
|
|
pagoFinal: number
|
|
}) {
|
|
return {
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function prefillApartadoLocal(itemId: string) {
|
|
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
|
if (!captura || captura.board !== 'APARTADO') throw new NotFoundError('Apartado no encontrado')
|
|
|
|
const tel = (captura.telefono ?? '').replace(/\D/g, '').slice(-10)
|
|
const cliente = await prisma.cliente.findFirst({
|
|
where: {
|
|
OR: [{ mondayApartadoId: itemId }, ...(tel !== '' ? [{ celular: { contains: tel } }] : [])],
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
const oportunidadLocal = await prisma.leadCaptura.findFirst({
|
|
where: {
|
|
board: 'OPORTUNIDADES',
|
|
...(tel !== '' ? { telefono: { contains: tel } } : { nombreLead: captura.nombreLead }),
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
})
|
|
|
|
// Perfil de la afluencia (datosJson): solo los campos que el front consume
|
|
let credito = ''
|
|
if (oportunidadLocal?.datosJson) {
|
|
try {
|
|
const datos = JSON.parse(oportunidadLocal.datosJson) as Record<string, unknown>
|
|
if (typeof datos['credito'] === 'string') credito = datos['credito']
|
|
} catch {
|
|
// datosJson inválido: se ignora, el prefill sale igual
|
|
}
|
|
}
|
|
|
|
// Cotización local más reciente: vinculada a la oportunidad local o por teléfono
|
|
let cotizacion = oportunidadLocal
|
|
? await prisma.cotizacion.findFirst({
|
|
where: { mondayItemId: oportunidadLocal.leadId },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
: null
|
|
if (!cotizacion && tel !== '') {
|
|
cotizacion = await prisma.cotizacion.findFirst({
|
|
where: { clienteTelefono: { contains: tel } },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
}
|
|
|
|
return {
|
|
apartado: {
|
|
id: captura.leadId,
|
|
nombre: captura.nombreLead,
|
|
unidadCodigo: cliente?.unidadCodigo ?? cotizacion?.unidadCodigo ?? '',
|
|
fechaLimite: '',
|
|
estado: captura.etapa,
|
|
vendedorIds: [] as number[],
|
|
telefono: captura.telefono ?? '',
|
|
email: captura.email ?? '',
|
|
precioFinal: cotizacion?.precioFinal ?? 0,
|
|
enganche: cotizacion?.enganche ?? 0,
|
|
},
|
|
contacto: cliente
|
|
? {
|
|
nombre: cliente.nombreCompleto,
|
|
Teléfono: cliente.celular ?? '',
|
|
Email: cliente.email ?? '',
|
|
CURP: cliente.curp,
|
|
RFC: cliente.rfc,
|
|
}
|
|
: null,
|
|
oportunidad: oportunidadLocal
|
|
? {
|
|
id: oportunidadLocal.leadId,
|
|
desarrollo: oportunidadLocal.desarrollo ?? '',
|
|
modeloInteres: oportunidadLocal.modelo ?? '',
|
|
credito,
|
|
}
|
|
: null,
|
|
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
|
}
|
|
}
|