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:
574
api-gateway/src/services/crm-local.service.ts
Normal file
574
api-gateway/src/services/crm-local.service.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
import type { TipoSync } from './sync-monday.service.js'
|
||||
|
||||
/**
|
||||
* CRM local (modo degradado): implementa el embudo completo contra SQLite
|
||||
* mientras Monday está caído y encola cada operación en SyncPendiente para que
|
||||
* el sync worker la replique cuando Monday vuelva.
|
||||
*
|
||||
* - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES |
|
||||
* APARTADO + etapa). Ids locales: `loc_<uuid>` (ver `esIdLocal`).
|
||||
* - Las operaciones que tocan varias tablas van en $transaction; el encolado
|
||||
* viaja en la MISMA transacción para que nunca quede un cambio local sin su
|
||||
* réplica pendiente.
|
||||
* - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras.
|
||||
*
|
||||
* Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false,
|
||||
* cuando el id es `loc_*` (siempre local hasta que se sincronice) o como
|
||||
* fallback tras un AppError 502 de Monday.
|
||||
*/
|
||||
|
||||
/** Board local del embudo (columna LeadCaptura.board) */
|
||||
export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO'
|
||||
|
||||
/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */
|
||||
const BOARD_PAYLOAD: Record<BoardLocal, 'leads' | 'oportunidades' | 'apartado'> = {
|
||||
LEADS: 'leads',
|
||||
OPORTUNIDADES: 'oportunidades',
|
||||
APARTADO: 'apartado',
|
||||
}
|
||||
|
||||
/** true si el id es local (aún no existe en Monday) */
|
||||
export function esIdLocal(id: string): boolean {
|
||||
return id.startsWith('loc_')
|
||||
}
|
||||
|
||||
function idLocal(): string {
|
||||
return `loc_${randomUUID()}`
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
/** Captura rápida de lead (forma corta de /hoy) */
|
||||
export interface DatosLeadLocal {
|
||||
nombre: string
|
||||
telefono?: string | undefined
|
||||
email?: string | undefined
|
||||
origen?: string | undefined
|
||||
desarrollo?: string | undefined
|
||||
notas?: string | undefined
|
||||
}
|
||||
|
||||
/** Alta Afluencia: captura ligera + perfilamiento completo */
|
||||
export interface DatosAfluenciaLocal extends DatosLeadLocal {
|
||||
/** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */
|
||||
oportunidadId?: string | undefined
|
||||
modelo?: string | undefined
|
||||
medioContacto?: string | undefined
|
||||
edad?: number | undefined
|
||||
estadoCivil?: string | undefined
|
||||
tipoEmpleo?: string | undefined
|
||||
ocupacion?: string | undefined
|
||||
rangoSalarial?: string | undefined
|
||||
credito?: string | undefined
|
||||
tipoEconomia?: string | undefined
|
||||
califica?: string | undefined
|
||||
loQuierePara?: string | undefined
|
||||
}
|
||||
|
||||
/** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */
|
||||
export type ResultadoCapturaLocal =
|
||||
| { id: string; oportunidadId: string | null; actualizada?: boolean }
|
||||
| { duplicado: { id: string; nombre: string } }
|
||||
|
||||
/** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */
|
||||
export interface ResultadoBusquedaLocal {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
desarrollo: string
|
||||
modeloInteres: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del
|
||||
* modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` +
|
||||
* `payloadJson` y actualizan los ids Monday usando `referenciaLocal`.
|
||||
*/
|
||||
export async function encolarSync(
|
||||
tipo: TipoSync,
|
||||
payload: Record<string, unknown>,
|
||||
referenciaLocal?: string | null,
|
||||
): Promise<void> {
|
||||
await prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo,
|
||||
payloadJson: JSON.stringify(payload),
|
||||
referenciaLocal: referenciaLocal ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Versión best-effort para las réplicas que hoy solo se loguean (alta cliente,
|
||||
* cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA
|
||||
* rompe el flujo del caller.
|
||||
*/
|
||||
export async function encolarSyncSeguro(
|
||||
tipo: TipoSync,
|
||||
payload: Record<string, unknown>,
|
||||
referenciaLocal?: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await encolarSync(tipo, payload, referenciaLocal)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[sync] no se pudo encolar ${tipo}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export class CrmLocalService {
|
||||
/**
|
||||
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD
|
||||
* encolado. Dedup por teléfono entre los leads locales (mismo contrato 409
|
||||
* que el flujo Monday). Con usuarioId también se encola la auto-asignación
|
||||
* del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al
|
||||
* sincronizarse.
|
||||
*/
|
||||
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
||||
if (datos.telefono !== undefined) {
|
||||
const existente = await prisma.leadCaptura.findFirst({
|
||||
where: { telefono: datos.telefono, board: 'LEADS' },
|
||||
})
|
||||
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
||||
}
|
||||
const id = idLocal()
|
||||
const datosMonday = {
|
||||
nombre: datos.nombre,
|
||||
telefono: datos.telefono,
|
||||
email: datos.email,
|
||||
origenLabel: datos.origen,
|
||||
desarrolloLabel: datos.desarrollo,
|
||||
notas: datos.notas,
|
||||
}
|
||||
const operaciones = [
|
||||
prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: id,
|
||||
oportunidadId: null, // los leads no generan oportunidad
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
board: 'LEADS',
|
||||
etapa: 'NUEVO',
|
||||
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
|
||||
}),
|
||||
]
|
||||
if (usuarioId !== undefined) {
|
||||
operaciones.push(
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'ASIGNAR_PERSONA',
|
||||
payloadJson: JSON.stringify({ board: 'leads', usuarioId }),
|
||||
referenciaLocal: id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
await prisma.$transaction(operaciones)
|
||||
return { id, oportunidadId: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES
|
||||
* etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos
|
||||
* planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con
|
||||
* oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila
|
||||
* local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday).
|
||||
*/
|
||||
async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
||||
const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos
|
||||
const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos
|
||||
const datosMonday = {
|
||||
nombre: datos.nombre,
|
||||
telefono: datos.telefono,
|
||||
email: datos.email,
|
||||
desarrolloLabel: datos.desarrollo,
|
||||
origenLabel: datos.origen,
|
||||
modeloLabel: modelo,
|
||||
medioContacto,
|
||||
edad,
|
||||
estadoCivilLabel: estadoCivil,
|
||||
tipoEmpleoLabel: tipoEmpleo,
|
||||
ocupacion,
|
||||
rangoSalarial,
|
||||
creditoLabel: credito,
|
||||
tipoEconomiaLabel: tipoEconomia,
|
||||
calificaLabel: califica,
|
||||
loQuierePara,
|
||||
notas: datos.notas,
|
||||
}
|
||||
|
||||
if (oportunidadId !== undefined) {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
||||
const operaciones = []
|
||||
if (existente) {
|
||||
operaciones.push(
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: oportunidadId },
|
||||
data: {
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
modelo: modelo ?? null,
|
||||
etapa: 'Afluencia',
|
||||
datosJson: JSON.stringify(datos),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
operaciones.push(
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'ACTUALIZAR_AFLUENCIA',
|
||||
payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }),
|
||||
referenciaLocal: oportunidadId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await prisma.$transaction(operaciones)
|
||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||
}
|
||||
|
||||
if (datos.telefono !== undefined) {
|
||||
const duplicado = await prisma.leadCaptura.findFirst({
|
||||
where: { telefono: datos.telefono, board: 'OPORTUNIDADES' },
|
||||
})
|
||||
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
||||
}
|
||||
|
||||
const id = idLocal()
|
||||
const operaciones = [
|
||||
prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: id,
|
||||
oportunidadId: id, // la afluencia ES la oportunidad
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
modelo: modelo ?? null,
|
||||
board: 'OPORTUNIDADES',
|
||||
etapa: 'Afluencia',
|
||||
datosJson: JSON.stringify(datos),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
// Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de
|
||||
// clientes encuentra así la oportunidad a mover a 'Aparto'
|
||||
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
|
||||
prisma.syncPendiente.create({
|
||||
data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
|
||||
}),
|
||||
]
|
||||
if (usuarioId !== undefined) {
|
||||
operaciones.push(
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'ASIGNAR_PERSONA',
|
||||
payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }),
|
||||
referenciaLocal: id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
await prisma.$transaction(operaciones)
|
||||
return { id, oportunidadId: id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fila local del item, creándola "sombra" si no existe y `sombra` es true.
|
||||
* Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados
|
||||
* antes del modo local): la acción queda reflejada en Mi día y el cambio se
|
||||
* encola igual. Sin `sombra` → 404.
|
||||
*/
|
||||
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
|
||||
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
if (row) return row
|
||||
if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`)
|
||||
return prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: itemId,
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: 'Registro previo (Monday)',
|
||||
board,
|
||||
etapa: 'NUEVO',
|
||||
mondayItemId: esIdLocal(itemId) ? null : itemId,
|
||||
pendienteSync: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO):
|
||||
* actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el
|
||||
* label real de la columna 'Estado' (y la fecha de la cita si aplica).
|
||||
*/
|
||||
async cambiarEstado(
|
||||
itemId: string,
|
||||
label: string,
|
||||
board: BoardLocal,
|
||||
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
||||
): Promise<void> {
|
||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
|
||||
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
|
||||
await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: itemId },
|
||||
data: { etapa: label, pendienteSync: true },
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en
|
||||
* Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler
|
||||
* replica el estado + el item del tablero Motivos No Compra).
|
||||
*/
|
||||
async descartar(
|
||||
itemId: string,
|
||||
motivo: string,
|
||||
opciones: {
|
||||
fuente?: 'lead' | 'oportunidad' | undefined
|
||||
subcategoria?: string | undefined
|
||||
comentario?: string | undefined
|
||||
usuarioId?: string | undefined
|
||||
sombra?: boolean | undefined
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const fuente = opciones.fuente ?? 'oportunidad'
|
||||
const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES'
|
||||
const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||
const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido'
|
||||
const payload: Record<string, unknown> = { fuente, motivo, nombreItem: row.nombreLead }
|
||||
if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria
|
||||
if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario
|
||||
await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: itemId },
|
||||
data: {
|
||||
etapa: etapaTerminal,
|
||||
motivo,
|
||||
submotivo: opciones.subcategoria ?? null,
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
|
||||
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
|
||||
* los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se
|
||||
* encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo
|
||||
* Monday). Devuelve la fila local de la oportunidad.
|
||||
*/
|
||||
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
|
||||
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
|
||||
if (!lead) throw new NotFoundError('Lead no encontrado')
|
||||
if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
|
||||
|
||||
const oportunidadId = idLocal()
|
||||
const datosOportunidad = {
|
||||
nombre: lead.nombreLead,
|
||||
estadoLabel: 'Agendo Cita',
|
||||
telefono: lead.telefono ?? undefined,
|
||||
email: lead.email ?? undefined,
|
||||
origenLabel: lead.origen ?? undefined,
|
||||
desarrolloLabel: lead.desarrollo ?? undefined,
|
||||
modeloLabel: lead.modelo ?? undefined,
|
||||
}
|
||||
const resultados = await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: leadIdLocal },
|
||||
data: { etapa: 'Calificado', oportunidadId, pendienteSync: true },
|
||||
}),
|
||||
prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: oportunidadId,
|
||||
oportunidadId,
|
||||
usuarioId: lead.usuarioId,
|
||||
nombreLead: lead.nombreLead,
|
||||
telefono: lead.telefono,
|
||||
email: lead.email,
|
||||
origen: lead.origen,
|
||||
desarrollo: lead.desarrollo,
|
||||
modelo: lead.modelo,
|
||||
board: 'OPORTUNIDADES',
|
||||
etapa: 'Agendo Cita',
|
||||
datosJson: lead.datosJson,
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'CONVERSION',
|
||||
payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }),
|
||||
referenciaLocal: leadIdLocal,
|
||||
},
|
||||
}),
|
||||
])
|
||||
return resultados[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
|
||||
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
|
||||
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
|
||||
* `apartado-creado:<opId>` que las automatizaciones. Encola CAMBIO_ETAPA +
|
||||
* CREAR_APARTADO (en ese orden: la etapa primero, como en Monday).
|
||||
*/
|
||||
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
|
||||
// Sombra para ids Monday sin fila local (fallback 502 sobre items viejos)
|
||||
const oportunidad = await this.filaLocal(
|
||||
oportunidadIdLocal,
|
||||
'OPORTUNIDADES',
|
||||
usuarioId,
|
||||
!esIdLocal(oportunidadIdLocal),
|
||||
)
|
||||
|
||||
const clave = claveApartadoCreado(oportunidadIdLocal)
|
||||
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||
if (previo !== null) {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } })
|
||||
if (existente) return existente
|
||||
}
|
||||
|
||||
const apartadoId = idLocal()
|
||||
const datosApartado = {
|
||||
clienteNombre: oportunidad.nombreLead,
|
||||
unidadCodigo: '', // la unidad se captura en la cotización/alta
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'Pago Apartado',
|
||||
telefono: oportunidad.telefono ?? undefined,
|
||||
email: oportunidad.email ?? undefined,
|
||||
}
|
||||
const resultados = await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: oportunidadIdLocal },
|
||||
data: { etapa: 'Aparto', pendienteSync: true },
|
||||
}),
|
||||
prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: apartadoId,
|
||||
usuarioId: oportunidad.usuarioId,
|
||||
nombreLead: oportunidad.nombreLead,
|
||||
telefono: oportunidad.telefono,
|
||||
email: oportunidad.email,
|
||||
desarrollo: oportunidad.desarrollo,
|
||||
modelo: oportunidad.modelo,
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
datosJson: oportunidad.datosJson,
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
prisma.automatizacionState.upsert({
|
||||
where: { clave },
|
||||
update: { valor: apartadoId },
|
||||
create: { clave, valor: apartadoId },
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'CAMBIO_ETAPA',
|
||||
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }),
|
||||
referenciaLocal: oportunidadIdLocal,
|
||||
},
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: {
|
||||
tipo: 'CREAR_APARTADO',
|
||||
payloadJson: JSON.stringify({ datos: datosApartado }),
|
||||
referenciaLocal: apartadoId,
|
||||
},
|
||||
}),
|
||||
])
|
||||
return resultados[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Búsqueda local para el picker del alta de clientes (y el buscador del
|
||||
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular,
|
||||
* con la misma forma de respuesta que la búsqueda Monday.
|
||||
*/
|
||||
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
})
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 15,
|
||||
})
|
||||
const candidatos = [
|
||||
...capturas.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.etapa,
|
||||
origen: c.origen ?? '',
|
||||
fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
modeloInteres: c.modelo ?? '',
|
||||
orden: c.updatedAt,
|
||||
})),
|
||||
...clientes.map((c) => ({
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: '',
|
||||
origen: '',
|
||||
fuente: 'oportunidad' as const,
|
||||
desarrollo: c.proyecto,
|
||||
modeloInteres: '',
|
||||
orden: c.createdAt,
|
||||
})),
|
||||
]
|
||||
return candidatos
|
||||
.sort((a, b) => b.orden.getTime() - a.orden.getTime())
|
||||
.slice(0, 15)
|
||||
.map(({ orden: _orden, ...resto }) => resto)
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
||||
export const crmLocal = new CrmLocalService()
|
||||
Reference in New Issue
Block a user