14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
561 lines
20 KiB
TypeScript
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,
|
|
}
|
|
}
|