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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user