feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
@@ -5,10 +5,13 @@ import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type DatosContacto } from '../services/monday.service.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'
|
||||
|
||||
// ---------- Alta de clientes ----------
|
||||
@@ -177,6 +180,8 @@ async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,8 +212,12 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
|
||||
async function crearApartadoSiAplica(
|
||||
/**
|
||||
* 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
|
||||
@@ -217,7 +226,7 @@ async function crearApartadoSiAplica(
|
||||
tiposCredito?: unknown
|
||||
},
|
||||
capturadoPorId: string | null,
|
||||
): Promise<string | null> {
|
||||
): Promise<DatosApartado | null> {
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
if (valorVenta <= 0) return null
|
||||
const enganche = aNumero(vc.enganche)
|
||||
@@ -230,7 +239,7 @@ async function crearApartadoSiAplica(
|
||||
})
|
||||
vendedorId = capturista?.mondayUserId ?? undefined
|
||||
}
|
||||
return mondayService.crearApartado({
|
||||
return {
|
||||
clienteNombre: dg.nombreCompleto,
|
||||
unidadCodigo: vc.ubicacion ?? '',
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
@@ -245,7 +254,59 @@ async function crearApartadoSiAplica(
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,21 +369,22 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
// 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({
|
||||
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) },
|
||||
})
|
||||
mondayContactoId = await mondayService.crearContacto(datosContacto)
|
||||
|
||||
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
|
||||
|
||||
@@ -333,6 +395,28 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
} 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 })
|
||||
@@ -440,17 +524,20 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
|
||||
},
|
||||
})
|
||||
|
||||
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó
|
||||
// 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({
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'PROSPECTO',
|
||||
})
|
||||
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 })
|
||||
@@ -466,6 +553,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
|
||||
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 {
|
||||
@@ -474,6 +562,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,17 +602,15 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
||||
// 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, {
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'CLIENTE',
|
||||
})
|
||||
await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente)
|
||||
} else {
|
||||
mondayContactoId = await mondayService.crearContacto({
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'CLIENTE',
|
||||
})
|
||||
mondayContactoId = await mondayService.crearContacto(datosContactoCliente)
|
||||
}
|
||||
|
||||
if (mondayApartadoId === null) {
|
||||
@@ -540,6 +627,32 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
||||
} 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 })
|
||||
@@ -614,19 +727,25 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
|
||||
)
|
||||
|
||||
// Prende el checkbox del documento en el item de Apartado del cliente
|
||||
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida.
|
||||
// (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) {
|
||||
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)
|
||||
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)
|
||||
@@ -654,31 +773,141 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
|
||||
*/
|
||||
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
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' },
|
||||
})
|
||||
// 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 } },
|
||||
@@ -686,22 +915,36 @@ export async function prefillApartadoEndpoint(req: Request, res: Response): Prom
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
apartado,
|
||||
contacto,
|
||||
oportunidad,
|
||||
cotizacion: cotizacion
|
||||
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
|
||||
? {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user