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

@@ -1,103 +1,39 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { VOCABULARIO, type MotivoNoCompra } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
/**
* Subcategorías por categoría, tomadas del tablero de referencia "Motivos No
* Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo).
* Sembradas como labels de la columna status 'Subcategoria' del tablero
* Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes'
* llevan las faltas de ortografía originales del tablero del cliente.
* Subcategorías por categoría, tomadas del catálogo del tablero de referencia
* "Motivos No Compra" del workspace CRM FRASA. NOTA: 'Mesualidad' y 'Cambo de
* Planes' llevan las faltas de ortografía originales del catálogo del cliente.
*/
export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(),
})
type MondayOportunidades = Pick<
MondayService,
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
>
/**
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
* registra el motivo en el tablero catálogo Motivos No Compra.
* Devuelve el ID del item creado en Motivos No Compra.
* Marca una oportunidad como perdida (etapa 'Perdido' + motivo/submotivo en la
* fila local) y encola el DESCARTE. SIEMPRE local.
*/
export async function perderOportunidad(
monday: MondayOportunidades,
mondayItemId: string,
motivo: string,
comentario?: string,
): Promise<string> {
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
let motivoItemId: string | null = null
// Modo local (id loc_* o Monday caído / fallback 502): descarte local +
// DESCARTE encolado; el motivo del tablero catálogo lo crea el worker
if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
} else {
try {
motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
}
}
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId: null })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo)
}
@@ -105,53 +41,20 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
/**
* Etapas del funnel que el asesor puede mover desde los botones de
* "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
* Oportunidades — el frontend los manda tal cual).
* "Mis oportunidades" (vocabulario canónico del CRM — el frontend las manda
* tal cual).
*/
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
'Agendo Cita',
'Afluencia',
'Cotizador',
'Aparto',
'No Asistio',
'Abandono',
] as const
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = VOCABULARIO.FASES_OPORTUNIDAD
const cambiarEtapaSchema = z.object({
etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
// En 'Agendo Cita' puede setear la columna date 'Fecha'
// En 'Agendo Cita' puede setear la fecha de la cita
fecha: z.string().optional(),
})
type MondayEtapa = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
* cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
* (idempotente; el webhook de change haría lo mismo, pero así la UI responde
* al instante).
*/
export async function cambiarEtapaOportunidad(
monday: MondayEtapa,
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha?: string,
): Promise<void> {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
if (etapa === 'Agendo Cita' && fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
}
/**
* Cambio de etapa en modo local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* el equivalente al asegurarApartado del flujo Monday.
* Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/
async function cambiarEtapaLocal(
@@ -185,67 +88,16 @@ async function cambiarEtapaLocal(
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
} else {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
} catch (err) {
if (!esFalloMonday(err)) throw err
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err2) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err2)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
return
}
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
try {
await automatizacionesService.asegurarApartado(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
}
// Bloquear la unidad del inventario (colocar en APARTADA)
try {
const boardOportunidades = config.monday.boards.oportunidades
const svc = mondayService as MondayService
const item = await svc.getItemConGrupo(itemId)
if (item) {
const colUnidad = await svc.columnaIdPorTitulo(boardOportunidades, 'Unidad')
const unidadCodigo = colUnidad
? (item.column_values.find((cv) => cv.id === colUnidad)?.text ?? '').trim()
: ''
if (unidadCodigo !== '') {
await svc.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad de la oportunidad ${itemId}:`, err)
}
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
}