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 = { SOLTERO: 'Soltero', CASADO: 'Casado', UNION_LIBRE: 'Union Libre', DIVORCIADO: 'Divorciado', VIUDO: 'Viudo', } const VIVIENDA_ACTUAL_LABEL: Record = { PRESTADA: 'Prestada', RENTADA: 'Rentada', FAMILIAR: 'Comparte con familia', PROPIA: 'Propia', } const PROPOSITO_COMPRA_LABEL: Record = { 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 = { 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:` 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): Promise { 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 = { 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 { 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 { 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 { 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 { 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 { 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) // 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, clave: string): Record => { const v = datos[clave] return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {} } 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 | 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): Omit { 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 { 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), 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 { 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 { 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) // 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), 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 { 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 = { '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 { 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 { 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 { 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 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, } }