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.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -2,36 +2,20 @@ 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.
* CRM local: implementa el embudo completo contra SQLite (tabla LeadCaptura).
*
* - 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.
* - Las operaciones que tocan varias tablas van en $transaction.
* - 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) */
/** true si el id es local (`loc_<uuid>`) en lugar de un id heredado del CRM anterior */
export function esIdLocal(id: string): boolean {
return id.startsWith('loc_')
}
@@ -40,14 +24,6 @@ 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
@@ -93,50 +69,10 @@ export interface ResultadoBusquedaLocal {
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.
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO'. Dedup por
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizando: últimos 10 dígitos)
@@ -157,111 +93,52 @@ export class CrmLocalService {
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.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 }
const fila = await 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,
},
})
return { id: fila.leadId, 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).
* planos) + auto-mapping LeadOportunidad. Con oportunidadId (botón
* "Afluencia" de Mis oportunidades): actualiza la fila local si existe.
*/
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,
}
const { oportunidadId, modelo } = datos
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({
await prisma.leadCaptura.update({
where: { leadId: oportunidadId },
data: {
tipo: 'ACTUALIZAR_AFLUENCIA',
payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }),
referenciaLocal: oportunidadId,
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,
},
}),
)
await prisma.$transaction(operaciones)
})
}
return { id: oportunidadId, oportunidadId, actualizada: true }
}
@@ -273,7 +150,7 @@ export class CrmLocalService {
}
const id = idLocal()
const operaciones = [
await prisma.$transaction([
prisma.leadCaptura.create({
data: {
leadId: id,
@@ -291,33 +168,17 @@ export class CrmLocalService {
pendienteSync: true,
},
}),
// Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
// Auto-mapping leadId = oportunidadId: 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.
* Las sombras cubren ids heredados del CRM anterior sin fila local: la
* acción queda reflejada en Mi día. Sin `sombra` → 404.
*/
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
@@ -327,7 +188,7 @@ export class CrmLocalService {
data: {
leadId: itemId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: 'Registro previo (Monday)',
nombreLead: 'Registro previo (CRM anterior)',
board,
etapa: 'NUEVO',
mondayItemId: esIdLocal(itemId) ? null : itemId,
@@ -338,8 +199,8 @@ export class CrmLocalService {
/**
* 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).
* actualiza `etapa` (+updatedAt vía Prisma) y guarda la fecha de la cita en
* datosJson si aplica.
*/
async cambiarEstado(
itemId: string,
@@ -348,8 +209,6 @@ export class CrmLocalService {
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
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
@@ -361,21 +220,15 @@ export class CrmLocalService {
datos['fechaCita'] = opciones.fecha
updateData['datosJson'] = JSON.stringify(datos)
}
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: updateData,
}),
prisma.syncPendiente.create({
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
await prisma.leadCaptura.update({
where: { leadId: itemId },
data: updateData,
})
}
/**
* 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).
* Oportunidades); guarda motivo/submotivo.
*/
async descartar(
itemId: string,
@@ -392,31 +245,22 @@ export class CrmLocalService {
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 },
}),
])
await prisma.leadCaptura.update({
where: { leadId: itemId },
data: {
etapa: etapaTerminal,
motivo,
submotivo: opciones.subcategoria ?? null,
pendienteSync: true,
},
})
}
/**
* 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.
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el
* lead ya fue convertido → 409. Devuelve la fila local de la oportunidad.
*/
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
@@ -424,15 +268,6 @@ export class CrmLocalService {
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 },
@@ -456,13 +291,6 @@ export class CrmLocalService {
},
}),
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]
}
@@ -471,11 +299,10 @@ export class CrmLocalService {
* "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).
* `apartado-creado:<opId>` de las automatizaciones.
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids Monday sin fila local (fallback 502 sobre items viejos)
// Sombra para ids heredados sin fila local
const oportunidad = await this.filaLocal(
oportunidadIdLocal,
'OPORTUNIDADES',
@@ -491,15 +318,6 @@ export class CrmLocalService {
}
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 },
@@ -525,28 +343,13 @@ export class CrmLocalService {
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.
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
*/
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
const capturas = await prisma.leadCaptura.findMany({