Files
FRASA-CAS/api-gateway/src/controllers/clientes.controller.ts
Consult AS Technology 5063ffe281 fix(apartados): rename firma→alta enkontrol + campo usuario + bloqueo unidad
- 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
2026-09-08 06:31:41 +00:00

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,
}
}