Files
FRASA-CAS/api-gateway/src/controllers/clientes.controller.ts
Consult AS Technology 89d95eb53b feat(purga): Monday eliminado completamente del sistema
14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
2026-09-11 11:47:17 +00:00

561 lines
20 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 { 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<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',
}
/**
* "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, unknown>): 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<string, unknown>,
capturadoPorId: string | null,
): Promise<string | null> {
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<void> {
try {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } })
if (!captura) return
let datos: Record<string, unknown> = {}
if (captura.datosJson) {
try { datos = JSON.parse(captura.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? ''
if (valorVenta > 0) datos['precioFinal'] = valorVenta
if (enganche > 0) {
datos['enganche'] = enganche
datos['montoApartado'] = enganche
}
datos['estadoLabel'] = 'Pago Apartado'
datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0)
datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15)
datos['fechaCompra'] = hoyMas(0)
const formaPago = formaPagoLabel(vc.tiposCredito)
if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago
if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular
if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email
if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp
await prisma.leadCaptura.update({
where: { leadId: apartadoId },
data: {
datosJson: JSON.stringify(datos),
...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}),
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err)
}
}
/**
* 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<void> {
try {
await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err)
}
}
/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
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<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, req.usuarioToken?.sub ?? null)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(cliente.id, mondayApartadoId)
}
res.status(201).json({ ...cliente, mondayContactoId: 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<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),
},
})
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<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) } })
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<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,
},
})
// Apartado del alta (misma lógica que el alta directa)
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, cliente.capturadoPorId)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(id, mondayApartadoId)
}
res.status(201).json({ ...actualizado, mondayContactoId: null, 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,
},
}),
),
)
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 para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis
* apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto
* relacionado (ficha del Cliente), oportunidad relacionada (perfil de la
* afluencia) y la cotización más reciente vinculada. Las relaciones se arman
* por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
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<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,
}
}