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 { prisma } from '../services/prisma.js' import { crmLocal } from '../services/crm-local.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; viajan en el payload del alta) 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 /** * Checkbox que corresponde a un tipo de documento en el expediente del * apartado. INE y el resto no tienen checkbox (devuelve null). */ export function checkboxParaTipoDocumento(tipo: string): string | null { switch (tipo.trim().toUpperCase()) { 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}` } /** Label de "Forma de Pago" 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', } /** * "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del * wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno. */ export function formaPagoLabel(tiposCredito: unknown): string | undefined { if (!Array.isArray(tiposCredito)) return undefined for (const t of tiposCredito) { const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()] if (label !== undefined) return label } return undefined } // ---------- Helpers locales compartidos (alta directa y alta progresiva) ---------- const apartadoLigadoDeBody = (body: Record): string | null => { const id = body['apartadoMondayId'] return typeof id === 'string' && id !== '' ? id : null } /** * Si el alta viene de una afluencia del funnel (la app alta-clientes manda * leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se * crea el apartado local (crmLocal.apartar; idempotente por dedup). El id * puede ser una oportunidad directa (modelo nuevo: la afluencia ES la * oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se * resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup. * Devuelve el id de la fila de apartado local creada (null si no aplica). * Un fallo aquí no rompe el alta. */ async function moverLeadAApartoSiAplica( body: Record, capturadoPorId: string | null, ): Promise { const leadId = body['leadMondayId'] if (typeof leadId !== 'string' || leadId === '') return null try { const comoOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId }, }) const mapping = comoOportunidad ?? (await prisma.leadOportunidad.findUnique({ where: { leadId } })) const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null if (oportunidadId === null) return null const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined) return apartado.leadId } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err) return null } } /** * Enriquece la fila de apartado local con lo capturado en el alta (unidad, * precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un * apartado encuentra así la ficha completa al prellenar. Best-effort. */ async function enriquecerApartadoLocal( apartadoId: string, 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 }, ): Promise { try { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } }) if (!captura) return let datos: Record = {} if (captura.datosJson) { try { datos = JSON.parse(captura.datosJson) as Record } catch { /* ignorar */ } } const valorVenta = aNumero(vc.valorVenta) const enganche = aNumero(vc.enganche) datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? '' if (valorVenta > 0) datos['precioFinal'] = valorVenta if (enganche > 0) { datos['enganche'] = enganche datos['montoApartado'] = enganche } datos['estadoLabel'] = 'Pago Apartado' datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0) datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15) datos['fechaCompra'] = hoyMas(0) const formaPago = formaPagoLabel(vc.tiposCredito) if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp await prisma.leadCaptura.update({ where: { leadId: apartadoId }, data: { datosJson: JSON.stringify(datos), ...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}), ...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}), }, }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err) } } /** * Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva * el nombre histórico: es el id de la fila de apartado, local o heredado). * Best-effort: un fallo no rompe el alta. */ async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise { try { await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err) } } /** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */ 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, }, }) // Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis // apartados) se LIGA tal cual; si el alta viene del funnel se mueve la // oportunidad a 'Aparto' y se crea/enriquece el apartado local. let mondayApartadoId = apartadoLigadoDeBody(body as Record) if (mondayApartadoId === null) { const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, req.usuarioToken?.sub ?? null) if (apartadoFunnelId !== null) { mondayApartadoId = apartadoFunnelId await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } if (mondayApartadoId !== null) { await ligarApartadoACliente(cliente.id, mondayApartadoId) } res.status(201).json({ ...cliente, mondayContactoId: null, 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 se completan en los pasos siguientes. */ 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() /** POST /api/clientes/progreso/iniciar — crea el alta en progreso */ 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), }, }) res.status(201).json({ id: cliente.id, mondayContactoId: null }) } /** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */ 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) } }) res.json({ id, mondaySync: false }) } /** * 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, }, }) // Apartado del alta (misma lógica que el alta directa) let mondayApartadoId = apartadoLigadoDeBody(body as Record) if (mondayApartadoId === null) { const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, cliente.capturadoPorId) if (apartadoFunnelId !== null) { mondayApartadoId = apartadoFunnelId await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } if (mondayApartadoId !== null) { await ligarApartadoACliente(id, mondayApartadoId) } res.status(201).json({ ...actualizado, mondayContactoId: null, 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, }, }), ), ) 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 para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis * apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto * relacionado (ficha del Cliente), oportunidad relacionada (perfil de la * afluencia) y la cotización más reciente vinculada. Las relaciones se arman * por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado. */ export async function prefillApartadoEndpoint(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) res.json(await prefillApartadoLocal(itemId)) } /** Shape de la cotización en la respuesta del prefill */ 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: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o * sombra); el contacto sale del Cliente ligado, la oportunidad de la captura * board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la * cotización de la tabla local. */ async function prefillApartadoLocal(itemId: string) { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) 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, } }