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:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

@@ -14,6 +14,7 @@
"test": "vitest run",
"seed:ai": "tsx scripts/seed-ai.ts",
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"setup:crm": "tsx scripts/setup-crm.ts",
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
"sync:etapas": "tsx scripts/sync-etapas.ts",

View File

@@ -0,0 +1,72 @@
-- Modo local (degradado) con sincronización a Monday:
-- - LeadCaptura pasa a ser la entidad local del embudo (board/etapa/perfil +
-- bandera pendienteSync).
-- - Unidad: espejo local del inventario (fallback del cotizador).
-- - SyncPendiente: cola FIFO de réplicas a Monday.
-- Patrón manual (drift histórico): db execute + migrate resolve. NUNCA migrate dev.
-- AlterTable LeadCaptura (SQLite no soporta IF NOT EXISTS en ADD COLUMN;
-- columnas verificadas ausentes con PRAGMA table_info antes de escribir esto)
ALTER TABLE "LeadCaptura" ADD COLUMN "board" TEXT NOT NULL DEFAULT 'LEADS';
ALTER TABLE "LeadCaptura" ADD COLUMN "etapa" TEXT NOT NULL DEFAULT 'NUEVO';
ALTER TABLE "LeadCaptura" ADD COLUMN "origen" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "desarrollo" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "modelo" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "email" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "motivo" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "submotivo" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "datosJson" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "mondayItemId" TEXT;
ALTER TABLE "LeadCaptura" ADD COLUMN "pendienteSync" BOOLEAN NOT NULL DEFAULT false;
-- SQLite no admite DEFAULT CURRENT_TIMESTAMP en ADD COLUMN: default constante
-- + backfill. Prisma siempre escribe updatedAt al crear/actualizar (@updatedAt).
ALTER TABLE "LeadCaptura" ADD COLUMN "updatedAt" DATETIME NOT NULL DEFAULT '2026-09-05 00:00:00';
-- Backfill: rows viejas ya estaban en Monday; su item es leadId
UPDATE "LeadCaptura" SET "updatedAt" = CURRENT_TIMESTAMP, "mondayItemId" = "leadId";
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LeadCaptura_mondayItemId_idx" ON "LeadCaptura"("mondayItemId");
-- CreateTable Unidad
CREATE TABLE IF NOT EXISTS "Unidad" (
"id" TEXT NOT NULL PRIMARY KEY,
"codigo" TEXT NOT NULL,
"desarrollo" TEXT NOT NULL,
"modelo" TEXT NOT NULL DEFAULT '',
"manzana" TEXT NOT NULL DEFAULT '',
"lote" TEXT NOT NULL DEFAULT '',
"supConstruccion" REAL NOT NULL DEFAULT 0,
"supExcedente" REAL NOT NULL DEFAULT 0,
"supEstacionamiento" REAL NOT NULL DEFAULT 0,
"precioLista" REAL NOT NULL DEFAULT 0,
"precioAcabados" REAL NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DISPONIBLE',
"etapa" TEXT NOT NULL DEFAULT '',
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Unidad_codigo_key" ON "Unidad"("codigo");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Unidad_desarrollo_idx" ON "Unidad"("desarrollo");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Unidad_status_idx" ON "Unidad"("status");
-- CreateTable SyncPendiente
CREATE TABLE IF NOT EXISTS "SyncPendiente" (
"id" TEXT NOT NULL PRIMARY KEY,
"tipo" TEXT NOT NULL,
"payloadJson" TEXT NOT NULL,
"referenciaLocal" TEXT,
"estado" TEXT NOT NULL DEFAULT 'PENDIENTE',
"intentos" INTEGER NOT NULL DEFAULT 0,
"ultimoError" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "SyncPendiente_estado_createdAt_idx" ON "SyncPendiente"("estado", "createdAt");

View File

@@ -157,16 +157,86 @@ model LeadOportunidad {
// Registro local de qué usuario del ecosistema capturó cada lead (alta de
// afluencia). Monday no guarda al capturista (el creador del item siempre es
// el token del API); esta tabla alimenta "Mis capturas" del panel Mi día.
// Además es la entidad local del embudo en modo local (Monday caído): guarda
// el board/etapa actuales y el perfil de la captura hasta que el sync worker
// la replique en Monday.
model LeadCaptura {
id String @id @default(uuid())
leadId String @unique // item del tablero Leads (Monday)
leadId String @unique // item del tablero Leads (Monday); en modo local: id local loc_<uuid>
oportunidadId String? // item del tablero Oportunidades ligado al capturar
usuarioId String // Usuario.id del capturista
nombreLead String
telefono String?
// Board del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
board String @default("LEADS")
// Etapa/estado local actual (NUEVO al capturar)
etapa String @default("NUEVO")
origen String?
desarrollo String?
modelo String?
email String?
// Motivo / submotivo del descarte
motivo String?
submotivo String?
// Perfilamiento completo de afluencia / payload extra (JSON serializado)
datosJson String?
// Item de Monday una vez sincronizado (rows viejas: = leadId)
mondayItemId String?
// true = falta replicar a Monday (lo drena el sync worker)
pendienteSync Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@@index([usuarioId])
@@index([mondayItemId])
}
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
// scripts/seed-unidades-locales.js desde el INVENTARIO_SEED del cotizador y lo
// mantiene fresco el write-through de monday.service.getUnidades(); es el
// fallback de lectura del cotizador en modo local.
model Unidad {
id String @id @default(uuid())
codigo String @unique // 'U-101', 'ATELIER-10', 'SENDA-411-1-7', ...
desarrollo String // DIAGONAL | ATELIER | ESTANZA | SENDA | SOLANA
modelo String @default("")
manzana String @default("")
lote String @default("")
supConstruccion Float @default(0)
supExcedente Float @default(0)
supEstacionamiento Float @default(0)
precioLista Float @default(0)
precioAcabados Float @default(0)
status String @default("DISPONIBLE") // DISPONIBLE | APARTADO | VENDIDO | ESCRITURADO | BLOQUEADO | ...
etapa String @default("") // etapa del pipeline de venta ('' si no aplica)
updatedAt DateTime @updatedAt @default(now())
@@index([desarrollo])
@@index([status])
}
// Cola de sincronización a Monday (modo local). Cada operación hecha contra la
// base local mientras Monday está caído se encola aquí; sync-monday.service la
// drena FIFO (createdAt asc) cuando Monday vuelve. Estados: PENDIENTE |
// PROCESANDO | HECHO | ERROR. Idempotencia vía AutomatizacionState 'sync:<id>'.
model SyncPendiente {
id String @id @default(uuid())
// CREAR_LEAD | CREAR_AFLUENCIA | ACTUALIZAR_AFLUENCIA | CAMBIO_ETAPA |
// DESCARTE | CONVERSION | CREAR_CONTACTO | ACTUALIZAR_CONTACTO |
// CREAR_APARTADO | COTIZACION_OPORTUNIDAD | CHECKBOX_DOC |
// APARTO_OPORTUNIDAD | LIQUIDADO_ESCRITURAR | ASIGNAR_PERSONA |
// DISPONIBILIDAD_UNIDAD
tipo String
payloadJson String
// Referencia local (leadId / clienteId / cotizacionId) para actualizar ids
referenciaLocal String?
estado String @default("PENDIENTE")
intentos Int @default(0)
ultimoError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([estado, createdAt])
}
// Estado persistente de las automatizaciones de Monday (round-robin, recordatorios).

View File

@@ -0,0 +1,77 @@
/**
* Siembra la tabla local `Unidad` (espejo del inventario para el modo local)
* desde el INVENTARIO_SEED curado del cotizador
* (apps/cotizador/src/data/unidades.ts, generado de los Excel de precios).
*
* Idempotente: upsert por `codigo`. Se corre una vez en el deploy y cuando el
* seed del cotizador se regenere; el write-through de
* monday.service.getUnidades() mantiene fresco el espejo en operación normal.
*
* Uso: npm run seed:unidades (tsx, porque la fuente es TypeScript)
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { INVENTARIO_SEED } from '../../apps/cotizador/src/data/unidades.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
const numero = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const texto = (v) => (v === null || v === undefined ? '' : String(v))
function mapearUnidad(u) {
const extras = u.extras ?? {}
return {
codigo: u.codigo,
desarrollo: texto(u.desarrollo) || 'DIAGONAL',
modelo: texto(u.modelo),
manzana: texto(extras.manzana),
lote: texto(extras.lote),
supConstruccion: numero(u.supConstruccion),
supExcedente: numero(u.supExcedente),
supEstacionamiento: numero(u.supEstacionamiento),
precioLista: numero(u.precioSinAcabados),
precioAcabados: numero(u.precioConAcabados),
status: texto(u.status) || 'DISPONIBLE',
// En SOLANA la etapa de obra vive en extras.etapa; en el tablero Monday es
// la columna "Etapa" (top-level en el tipo del cotizador)
etapa: texto(u.etapa) || texto(extras.etapa),
}
}
async function main() {
let insertadas = 0
let actualizadas = 0
for (const cruda of INVENTARIO_SEED) {
const u = mapearUnidad(cruda)
const previa = await prisma.unidad.findUnique({ where: { codigo: u.codigo }, select: { id: true } })
await prisma.unidad.upsert({
where: { codigo: u.codigo },
update: u,
create: u,
})
if (previa) actualizadas++
else insertadas++
}
const total = await prisma.unidad.count()
const porDesarrollo = await prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } })
// eslint-disable-next-line no-console
console.log(
`[seed-unidades] ${insertadas} insertada(s), ${actualizadas} actualizada(s); total en tabla: ${total}`,
)
for (const d of porDesarrollo) {
// eslint-disable-next-line no-console
console.log(` ${d.desarrollo}: ${d._count.codigo}`)
}
}
main()
.catch((err) => {
console.error('[seed-unidades] error:', err)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())

View File

@@ -1,9 +1,37 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEstadoApartado,
cambiarEstadoApartadoEndpoint,
FASES_APARTADO_PERMITIDAS,
subirDocumentosApartado,
TIPOS_DOCUMENTO_APARTADO,
} from './apartados.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
cliente: {
create: vi.fn(),
findFirst: vi.fn(),
},
documento: {
create: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
function mondayMock(grupoActual = 'topics') {
@@ -71,3 +99,85 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
// ---------- Modo local (saludMonday caído / ids loc_*) ----------
describe('ruteo modo local (apartados)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' })
const { res, json } = resFake()
await cambiarEstadoApartadoEndpoint(
{ params: { itemId: 'loc_apt' }, body: { estado: 'Alta Enkontrol' } } as unknown as Request,
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_apt' },
data: { etapa: 'Alta Enkontrol', pendienteSync: true },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' })
})
it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
// El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' })
const archivo = {
originalname: 'curp.pdf',
path: '/tmp/uploads/apartados/loc_apt/CURP_1.pdf',
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
const { res, status } = resFake()
await subirDocumentosApartado(
{ params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request,
res,
)
// Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }),
})
// El checkbox no se intenta en Monday: queda encolado para el worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'CHECKBOX_DOC',
payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }),
referenciaLocal: 'loc_apt',
}),
})
expect(status).toHaveBeenCalledWith(201)
})
})

View File

@@ -8,6 +8,9 @@ import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
/**
@@ -54,7 +57,24 @@ export async function cambiarEstadoApartado(
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoApartado(mondayService, itemId, body.estado)
// Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local
// + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
await cambiarEstadoApartado(mondayService, itemId, body.estado)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: true,
})
}
}
res.json({ itemId, estado: body.estado })
}
@@ -104,12 +124,28 @@ export const uploadDocumentosApartado = multer({
/**
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
* los documentos tengan dónde vivir.
* los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday
* está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del
* apartado (board APARTADO, creada por el "Aparto" local) como fallback.
*/
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
if (existente) return existente
const nombre = await mondayService.obtenerNombreItem(itemId)
let nombre: string | null = null
if (!esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
nombre = await mondayService.obtenerNombreItem(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err)
}
}
if (nombre === null) {
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
nombre = captura?.nombreLead ?? 'Cliente'
}
return prisma.cliente.create({
data: {
nombreCompleto: nombre,
@@ -151,14 +187,20 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom
)
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
// checkbox); un fallo de Monday no rompe la subida.
// checkbox); un fallo de Monday no rompe la subida: queda encolado
// (CHECKBOX_DOC) y el worker lo aplica al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null) {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
} else {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
}
}
}
res.status(201).json(documentos)

View File

@@ -17,6 +17,9 @@ const prismaMock = vi.hoisted(() => ({
usuario: {
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
@@ -276,3 +279,98 @@ describe('crearCliente — Apartado con ficha completa', () => {
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
})
})
describe('réplica diferida (encolado cuando Monday falla)', () => {
const altaConVenta = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 },
}
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.syncPendiente.create.mockResolvedValue({})
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO'])
// El payload del Contacto lleva los datos del alta (el worker no necesita nada más)
const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({
nombre: 'Cliente Demo',
estadoLabel: 'CLIENTE',
curp: 'CURP123',
})
expect(llamadaContacto.data.referenciaLocal).toBe('cli-1')
// El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar
const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({
clienteId: 'cli-1',
datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' },
})
})
it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1', mondayApartadoId: null },
})
})
it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD'])
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'APARTO_OPORTUNIDAD',
payloadJson: JSON.stringify({ leadId: 'lead-9' }),
referenciaLocal: 'lead-9',
}),
})
})
})

View File

@@ -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,
}
}

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getUsuariosWorkspace: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const capturaLocal = {
id: 'row-1',
leadId: 'loc_1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: true,
usuarioId: 'u-1',
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
})
describe('listarContactosEndpoint (fallback local)', () => {
it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
expect(mondayMock.getContactos).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
})
it('Monday lanza 502: fallback local', async () => {
mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' })
})
})
describe('detalleContactoEndpoint (fallback local)', () => {
it('Monday caído: detalle 100% local con la misma forma', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)
})
it('Monday caído y el id no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
await expect(
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(json).toHaveBeenCalledOnce()
mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado'))
await expect(
detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
})

View File

@@ -1,15 +1,28 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
import { detalleContactoLocal, listarContactosLocal } from '../services/contactos-local.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
*/
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await mondayService.getContactos())
if (!saludMonday.estaDisponible()) {
res.json(await listarContactosLocal())
return
}
try {
res.json(await mondayService.getContactos())
} catch (err) {
if (!esFalloMonday(err)) throw err
res.json(await listarContactosLocal())
}
}
/**
@@ -21,74 +34,91 @@ export async function listarContactosEndpoint(_req: Request, res: Response): Pro
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
const contacto = await mondayService.getContactoDetalle(contactoId)
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
// Monday caído (circuit breaker): detalle 100% local
if (!saludMonday.estaDisponible()) {
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
return
}
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
const clave = claveTelefono(contacto['Teléfono'] ?? '')
const capturasTodas = await prisma.leadCaptura.findMany({
where: { telefono: { not: null } },
orderBy: { createdAt: 'desc' },
})
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
try {
const contacto = await mondayService.getContactoDetalle(contactoId)
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
const clienteCrudo = await prisma.cliente.findFirst({
where: {
OR: [
{ mondayContactoId: contactoId },
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
],
},
include: { _count: { select: { documentos: true } } },
orderBy: { createdAt: 'desc' },
})
// Nombres de capturistas (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...capturasCrudas.map((c) => c.usuarioId),
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
]),
]
const usuariosLocal = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
const capturas = capturasCrudas.map((c) => ({
leadId: c.leadId,
nombreLead: c.nombreLead,
createdAt: c.createdAt,
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
}))
const clienteLocal = clienteCrudo
? {
...clienteCrudo,
capturadoPor:
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
: null,
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
: null
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
const clave = claveTelefono(contacto['Teléfono'] ?? '')
const capturasTodas = await prisma.leadCaptura.findMany({
where: { telefono: { not: null } },
orderBy: { createdAt: 'desc' },
})
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
const clienteCrudo = await prisma.cliente.findFirst({
where: {
OR: [
{ mondayContactoId: contactoId },
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
],
},
include: { _count: { select: { documentos: true } } },
orderBy: { createdAt: 'desc' },
})
// Nombres de capturistas (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...capturasCrudas.map((c) => c.usuarioId),
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
]),
]
const usuariosLocal = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
const capturas = capturasCrudas.map((c) => ({
leadId: c.leadId,
nombreLead: c.nombreLead,
createdAt: c.createdAt,
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
}))
const clienteLocal = clienteCrudo
? {
...clienteCrudo,
capturadoPor:
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
: null,
}
: null
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
} catch (err) {
// Monday se cayó a media lectura: detalle local (el 404 de "no existe en
// Monday" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
}

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidad: vi.fn(),
crearOportunidad: vi.fn(),
actualizarOportunidadCotizacion: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
import { crearCotizacion } from './cotizaciones.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del getUnidad obligatorio al guardar cotización: con Monday caído
* (o 502) la unidad sale del espejo local; si no existe en ninguno → 404.
*/
const filaUnidad = {
codigo: 'U-101',
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
etapa: '',
}
const bodyBase = {
unidadCodigo: 'U-101',
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
tipoPropuesta: 'BANCARIO' as const,
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
mondayMock.crearOportunidad.mockResolvedValue('monday-op-1')
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('crearCotizacion (getUnidad con fallback local)', () => {
it('Monday caído: cotiza con la unidad del espejo local (sin llamar a getUnidad)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, status, json } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL' })
expect(respuesta['propuesta']).toBeDefined()
})
it('Monday lanza 502 en getUnidad: mismo fallback local', async () => {
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, status } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(status).toHaveBeenCalledWith(201)
})
it('la unidad no existe ni en Monday ni en local → 404 como hoy', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
await expect(crearCotizacion({ body: bodyBase } as Request, res)).rejects.toThrow(NotFoundError)
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})

View File

@@ -8,6 +8,9 @@ const prismaMock = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
@@ -103,13 +106,38 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
})
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null)', async () => {
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => {
mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
// COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'COTIZACION_OPORTUNIDAD',
referenciaLocal: 'cot-1',
}),
})
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({
clienteTelefono: '4771234567',
datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' },
})
})
it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => {
mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {

View File

@@ -1,6 +1,7 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
import { obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { funnelService } from '../services/funnel.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
@@ -8,6 +9,7 @@ import {
type ResultadoAlertas,
} from '../services/alertas-inventario.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
@@ -50,7 +52,8 @@ export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
const body = crearCotizacionSchema.parse(req.body)
const unidad = await mondayService.getUnidad(body.unidadCodigo)
// Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno
const unidad = await obtenerUnidadConFallback(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
@@ -78,21 +81,23 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó
// persistida y la vinculación queda encolada (COTIZACION_OPORTUNIDAD) para
// que el worker la aplique cuando Monday vuelva
let mondayItemId: string | null = null
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}
try {
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
@@ -113,6 +118,15 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
await encolarSyncSeguro(
'COTIZACION_OPORTUNIDAD',
{
datos: datosOportunidad,
...(body.oportunidadId !== undefined ? { oportunidadId: body.oportunidadId } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
},
cotizacion.id,
)
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })

View File

@@ -1,25 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
buscarLeads,
buscarLeadsEndpoint,
cambiarEstadoLead,
cambiarEstadoLeadEndpoint,
convertirLead,
convertirLeadEndpoint,
crearAfluencia,
crearAfluenciaEndpoint,
crearLead,
crearLeadEndpoint,
descartarLead,
descartarLeadEndpoint,
ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js'
import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
@@ -32,6 +41,13 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
cliente: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -787,3 +803,217 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo"
expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined()
})
})
// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ----------
describe('ruteo modo local (Monday caído o fallback 502)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('crearLeadEndpoint con Monday caído: captura local 201 (id loc_*) y encola CREAR_LEAD sin tocar Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const spyCrear = vi.spyOn(mondayService, 'crearLead')
const { res, status, json } = resFake()
await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Local', telefono: '4771234567' } }), res)
expect(spyCrear).not.toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
const cuerpo = json.mock.calls[0]?.[0] as { id: string; asignacion: string }
expect(cuerpo.id).toMatch(/^loc_/)
expect(cuerpo).toMatchObject({ estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' })
expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA'])
// La fila local quedó board=LEADS etapa NUEVO pendiente de sincronizar
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
})
})
it('crearLeadEndpoint con Monday vivo que lanza 502: fallback a captura local (misma forma de respuesta)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, status, json } = resFake()
await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Fallback', telefono: '4771234567' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect((json.mock.calls[0]?.[0] as { id: string }).id).toMatch(/^loc_/)
expect(tiposEncolados()).toContain('CREAR_LEAD')
})
it('crearLeadEndpoint con error NO-502 de Monday: se propaga (no hay fallback)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(400, 'Origen inválido'))
await expect(
crearLeadEndpoint(reqCon({ body: { nombre: 'X', telefono: '4771234567' } }), resFake().res),
).rejects.toMatchObject({ status: 400 })
})
it('crearAfluenciaEndpoint con Monday caído: afluencia local 201 y encola CREAR_AFLUENCIA', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const spyCrear = vi.spyOn(mondayService, 'crearOportunidadAfluencia')
const { res, status, json } = resFake()
await crearAfluenciaEndpoint(reqCon({ body: { nombre: 'Afluencia Local', telefono: '6641112233', modelo: 'SOHO' } }), res)
expect(spyCrear).not.toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
const cuerpo = json.mock.calls[0]?.[0] as { id: string; oportunidadId: string }
expect(cuerpo.id).toMatch(/^loc_/)
expect(cuerpo.oportunidadId).toBe(cuerpo.id)
expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA'])
// Auto-mapping local leadId = oportunidadId
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: cuerpo.id, oportunidadId: cuerpo.id },
})
})
it('cambiarEstadoLeadEndpoint con id loc_* siempre va a local (aunque Monday esté vivo)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna')
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS' })
const { res, json } = resFake()
await cambiarEstadoLeadEndpoint(
reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }),
res,
)
expect(spyCambiar).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Contactado', pendienteSync: true },
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', estado: 'CONTACTADO' })
})
it('cambiarEstadoLeadEndpoint con id Monday y 502: crea fila sombra y encola CAMBIO_ETAPA', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await cambiarEstadoLeadEndpoint(
reqCon({ params: { itemId: '99887766' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }),
res,
)
// Sombra: el item Monday viejo queda reflejado localmente (Mi día) con su id como mondayItemId
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '99887766', board: 'LEADS', mondayItemId: '99887766' }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'CONTACTADO' })
})
it('descartarLeadEndpoint con id loc_*: descarte local, motivoItemId null y DESCARTE encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Lead Local' })
const { res, json } = resFake()
await descartarLeadEndpoint(
reqCon({ params: { itemId: 'loc_2' }, body: { motivo: 'Precio', subcategoria: 'Enganche' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche' }),
})
expect(tiposEncolados()).toEqual(['DESCARTE'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', estado: 'Perdido', motivo: 'Precio', motivoItemId: null })
})
it('convertirLeadEndpoint con id loc_*: conversión local 201 y CONVERSION encolada', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_lead',
oportunidadId: null,
usuarioId: 'u-1',
nombreLead: 'Lead Local',
telefono: '4771234567',
email: null,
origen: null,
desarrollo: null,
modelo: null,
datosJson: null,
})
const { res, status, json } = resFake()
await convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), res)
expect(status).toHaveBeenCalledWith(201)
const cuerpo = json.mock.calls[0]?.[0] as { leadId: string; oportunidadId: string; etapa: string }
expect(cuerpo.leadId).toBe('loc_lead')
expect(cuerpo.oportunidadId).toMatch(/^loc_/)
expect(cuerpo.etapa).toBe('Agendo Cita')
expect(tiposEncolados()).toEqual(['CONVERSION'])
})
it('convertirLeadEndpoint local con lead ya convertido → 409', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', oportunidadId: 'loc_op_previa' })
await expect(
convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), resFake().res),
).rejects.toMatchObject({ status: 409 })
})
it('buscarLeadsEndpoint con Monday caído busca en local (LeadCaptura + Cliente)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const spyLeads = vi.spyOn(mondayService, 'getLeads')
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_1',
nombreLead: 'Ana Local',
telefono: '4771234567',
email: null,
etapa: 'Afluencia',
origen: 'WhatsApp',
board: 'OPORTUNIDADES',
desarrollo: 'SENDA',
modelo: null,
updatedAt: new Date('2026-09-05T10:00:00Z'),
},
])
const { res, json } = resFake()
await buscarLeadsEndpoint(reqCon({ query: { q: 'ana' } }), res)
expect(spyLeads).not.toHaveBeenCalled()
expect(json).toHaveBeenCalledWith([
expect.objectContaining({ id: 'loc_1', nombre: 'Ana Local', fuente: 'oportunidad', estado: 'Afluencia' }),
])
})
})

View File

@@ -5,6 +5,9 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
@@ -402,7 +405,8 @@ export async function descartarLead(
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
* escritura; si la asignación falla, se apaga y cae al reparto).
* escritura; si la asignación falla, se apaga y cae al reparto — y queda
* encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
@@ -424,6 +428,8 @@ async function asignarCapturistaSiAplica(
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
// Best-effort: el worker la reintenta al drenar la cola
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId)
throw err
}
}
@@ -437,7 +443,23 @@ async function asignarCapturistaSiAplica(
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo
// actual con fallback a local si la llamada lanza 502. La respuesta mantiene
// la misma forma en ambos caminos (el front no se entera).
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearLead(body, usuarioSub)
} else {
try {
resultado = await crearLead(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearLead(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -446,16 +468,27 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
return
}
if (modoLocal) {
// Sin round-robin en modo local: el capturista queda asignado (su
// ASIGNAR_PERSONA viaja encolada tras el CREATE)
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
if (req.usuarioToken?.sub) {
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: req.usuarioToken.sub,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
@@ -465,14 +498,29 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const resultado = await crearAfluencia(mondayService, body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback
// a local tras 502. Misma forma de respuesta en ambos caminos.
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
} else {
try {
resultado = await crearAfluencia(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -487,16 +535,25 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
if (modoLocal) {
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) {
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: req.usuarioToken.sub,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
@@ -517,15 +574,51 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/**
* Cambio de estado en modo local: guarda la etapa (label real del board) en la
* fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502
* sobre items viejos) crea la fila "sombra" para que Mi día lo refleje.
*/
async function cambiarEstadoLocal(
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha: string | undefined,
fuente: FuenteLead,
usuarioId: string | undefined,
): Promise<void> {
const esLead = fuente === 'lead'
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', {
fecha,
usuarioId,
sombra: !esIdLocal(itemId),
})
}
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
const fuente = body.fuente ?? 'oportunidad'
// Los ids loc_* siempre van al path local (aún no existen en Monday); con el
// flag caído todo va a local; con Monday vivo, fallback a local tras 502.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
} else {
try {
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
} catch (err) {
if (!esFalloMonday(err)) throw err
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
}
}
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
@@ -533,7 +626,30 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
const fuente = body.fuente ?? 'oportunidad'
let motivoItemId: string | null = null
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
}
@@ -618,18 +734,39 @@ export async function convertirLead(
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = convertirSchema.parse(req.body ?? {})
const itemId = String(req.params['itemId'])
const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha)
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
const usuarioSub = req.usuarioToken?.sub
// Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la
// oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el
// fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front,
// como en el flujo normal.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
}
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
* Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
if (!saludMonday.estaDisponible()) {
res.json(await crmLocal.buscarLocales(q))
return
}
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}

View File

@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
usuario: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findMany: vi.fn(),
},
leadOportunidad: {
findMany: vi.fn(),
},
cliente: {
findMany: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUsuariosWorkspace: vi.fn(),
getLeads: vi.fn(),
getOportunidades: vi.fn(),
getActividades: vi.fn(),
getApartados: vi.fn(),
getUltimosCambios: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const usuarioFake = {
id: 'u-1',
nombre: 'Martin Zavala',
rol: 'vendedor',
activo: true,
mondayUserId: 123,
}
function capturaFake(parcial: Record<string, unknown> & { leadId: string }) {
return {
id: `row-${parcial.leadId}`,
nombreLead: `Persona ${parcial.leadId}`,
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: false,
usuarioId: 'u-1',
createdAt: new Date('2026-09-03T10:00:00'),
updatedAt: new Date('2026-09-04T10:00:00'),
...parcial,
}
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
prismaMock.usuario.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
mondayMock.getLeads.mockResolvedValue([])
mondayMock.getOportunidades.mockResolvedValue([])
mondayMock.getActividades.mockResolvedValue([])
mondayMock.getApartados.mockResolvedValue([])
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
})
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
])
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
expect(mondayMock.getLeads).not.toHaveBeenCalled()
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as unknown[]).length).toBe(2)
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
expect(resumen['misActividadesHoy']).toEqual([])
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
})
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
])
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1'])
})
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => {
mondayMock.getLeads.mockResolvedValue([
{
id: '9001',
nombre: 'Lead Monday',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
estado: 'Nuevo',
origen: 'Web',
telefono: '',
email: '',
vendedorIds: [123],
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
])
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
expect(leads[0]?.pendienteSync).toBe(true)
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
// Y la captura quedó marcada en Mis capturas
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
})
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(resumen['misLeads']).toEqual([])
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
})
it('un error no-Monday se propaga (no se traga nada)', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
})
})

View File

@@ -1,7 +1,9 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js'
import { resumenMiDia } from '../services/mi-dia.service.js'
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales } from '../services/mi-dia.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
@@ -62,16 +64,30 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
orderBy: { createdAt: 'desc' },
})
res.json(
await resumenMiDia(
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
if (!saludMonday.estaDisponible()) {
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
return
}
try {
const resumen = await resumenMiDia(
mondayService,
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
usuarioMiDia,
nombresPorMondayId,
capturas,
clientes,
new Date(),
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
capturadosIds,
),
)
)
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
} catch (err) {
// Monday se cayó a media lectura: mismo resumen, pero todo local
if (!esFalloMonday(err)) throw err
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
}
}

View File

@@ -1,9 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEtapaOportunidad,
cambiarEtapaOportunidadEndpoint,
ETAPAS_OPORTUNIDAD_PERMITIDAS,
perderOportunidad,
perderOportunidadEndpoint,
} from './oportunidades.controller.js'
import { mondayService } from '../services/monday.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra los cambios de columna y los motivos creados */
function mondayMock() {
@@ -120,3 +146,117 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
])
})
})
// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ----------
describe('ruteo modo local (endpoints de oportunidades)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('cambiarEtapaOportunidadEndpoint con id loc_*: etapa local + CAMBIO_ETAPA encolado, sin tocar Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // Monday vivo: el id loc_* manda
const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna')
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Cotizador' } }), res)
expect(spyCambiar).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', etapa: 'Cotizador' })
})
it("cambiarEtapaOportunidadEndpoint 'Aparto' con Monday caído: crea el apartado local (crmLocal.apartar)", async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: '6641112233',
email: null,
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: null,
})
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
// Oportunidad en Aparto + apartado local en Pago Apartado + cola
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { etapa: 'Aparto', pendienteSync: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
})
it('cambiarEtapaOportunidadEndpoint con id Monday y 502: fallback local con fila sombra', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'opp-88' }, body: { etapa: 'No Asistio' } }), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: 'opp-88', board: 'OPORTUNIDADES', mondayItemId: 'opp-88' }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' })
})
it('perderOportunidadEndpoint con Monday caído: descarte local (motivoItemId null) + DESCARTE encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' })
const { res, json } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_9' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }),
})
expect(tiposEncolados()).toEqual(['DESCARTE'])
expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null })
})
})

View File

@@ -4,6 +4,9 @@ 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'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -69,7 +72,29 @@ export async function perderOportunidad(
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
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 })
}
@@ -123,21 +148,55 @@ export async function cambiarEtapaOportunidad(
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.
*/
async function cambiarEtapaLocal(
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha: string | undefined,
usuarioId: string | undefined,
): Promise<void> {
if (etapa === 'Aparto') {
await crmLocal.apartar(itemId, usuarioId)
return
}
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
fecha,
usuarioId,
sombra: !esIdLocal(itemId),
})
}
/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
// 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') {
const usuarioSub = req.usuarioToken?.sub
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
} else {
try {
await automatizacionesService.asegurarApartado(itemId)
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
if (!esFalloMonday(err)) throw err
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
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)
}
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })

View File

@@ -1,6 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js'
// ---------- Prisma mockeado: el encolado de la réplica no toca la BD ----------
const prismaMock = vi.hoisted(() => ({
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna */
function mondayMock() {
return {
@@ -12,6 +22,11 @@ function mondayMock() {
}
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.syncPendiente.create.mockResolvedValue({})
})
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
const monday = mondayMock()
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
@@ -30,13 +45,21 @@ describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', ()
expect(monday.cambios).toEqual([])
})
it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => {
it('un fallo de Monday no rompe el abono: loguea, encola LIQUIDADO_ESCRITURAR y devuelve false', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(false)
expect(spyError).toHaveBeenCalled()
// La escrituración queda encolada para que el worker la aplique al volver Monday
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
})
spyError.mockRestore()
})
})

View File

@@ -1,6 +1,7 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import {
alCorriente,
avancePlan,
@@ -231,6 +232,8 @@ export async function notificarEscrituracionSiLiquidado(
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
// La escrituración queda encolada (LIQUIDADO_ESCRITURAR) para el worker
await encolarSyncSeguro('LIQUIDADO_ESCRITURAR', { mondayItemId: plan.mondayItemId }, plan.mondayItemId)
return false
}
}

View File

@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
cliente: {
findFirst: vi.fn(),
},
cotizacion: {
findFirst: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getApartados: vi.fn(),
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getOportunidades: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { prefillApartadoEndpoint } from './clientes.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del prefill del alta (GET /api/altas/prefill-apartado/:itemId):
* Monday caído/502 → el prefill se arma desde LeadCaptura (apartado local) +
* Cliente + cotización local, con los campos que el front ya consume.
*/
const apartadoLocal = {
id: 'row-a1',
leadId: 'loc_a1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: 'ana@correo.mx',
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'APARTADO',
etapa: 'Pago Apartado',
usuarioId: 'u-1',
datosJson: null,
mondayItemId: null,
pendienteSync: true,
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
const oportunidadLocal = {
...apartadoLocal,
id: 'row-o1',
leadId: 'loc_o1',
board: 'OPORTUNIDADES',
etapa: 'Aparto',
datosJson: JSON.stringify({ credito: 'Infonavit', edad: 35 }),
}
const clienteLocal = {
id: 'c1',
nombreCompleto: 'ANA DEMO',
curp: 'CURP123',
rfc: 'RFC123',
email: 'ana@correo.mx',
celular: '6649998877',
proyecto: 'SENDA',
unidadCodigo: 'SENDA-101',
mondayApartadoId: 'loc_a1',
createdAt: new Date('2026-09-02T11:00:00'),
}
const cotizacionLocal = {
id: 'cot1',
unidadCodigo: 'SENDA-101',
tipoPropuesta: 'INFONAVIT',
acabados: true,
apartado: 20000,
precioLista: 4_415_000,
precioFinal: 4_400_000,
enganche: 440_000,
numMensualidades: 12,
mensualidad: 30000,
pagoFinal: 100_000,
mondayItemId: 'loc_o1',
createdAt: new Date('2026-09-01T10:00:00'),
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.leadCaptura.findUnique.mockResolvedValue(apartadoLocal)
prismaMock.leadCaptura.findFirst.mockResolvedValue(oportunidadLocal)
prismaMock.cliente.findFirst.mockResolvedValue(clienteLocal)
prismaMock.cotizacion.findFirst.mockResolvedValue(cotizacionLocal)
})
describe('prefillApartadoEndpoint (fallback local)', () => {
it('Monday caído: prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect(mondayMock.getApartados).not.toHaveBeenCalled()
const prefill = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(prefill['apartado']).toMatchObject({
id: 'loc_a1',
nombre: 'ANA DEMO',
telefono: '6649998877',
email: 'ana@correo.mx',
unidadCodigo: 'SENDA-101',
estado: 'Pago Apartado',
precioFinal: 4_400_000,
})
expect(prefill['contacto']).toMatchObject({
nombre: 'ANA DEMO',
Teléfono: '6649998877',
CURP: 'CURP123',
RFC: 'RFC123',
})
expect(prefill['oportunidad']).toMatchObject({
id: 'loc_o1',
desarrollo: 'SENDA',
modeloInteres: '1R',
credito: 'Infonavit',
})
expect(prefill['cotizacion']).toMatchObject({ tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 4_400_000 })
})
it('Monday caído y el apartado no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
await expect(
prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
it('Monday lanza 502: cae al prefill local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getApartados.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as Record<string, unknown>)['apartado']).toMatchObject({ id: 'loc_a1' })
// Apartado inexistente en Monday (404): NO es fallo de Monday, se propaga
mondayMock.getApartados.mockResolvedValue([])
await expect(
prefillApartadoEndpoint({ params: { itemId: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
it('sin Cliente ni oportunidad local: contacto/oportunidad van en null (el front los tolera)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.cotizacion.findFirst.mockResolvedValue(null)
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
const prefill = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(prefill['contacto']).toBeNull()
expect(prefill['oportunidad']).toBeNull()
expect(prefill['cotizacion']).toBeNull()
expect(prefill['apartado']).toMatchObject({ id: 'loc_a1', unidadCodigo: '' })
})
})

View File

@@ -0,0 +1,15 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* GET /api/sistema/estado — salud del modo local para el banner del front:
* `mondayOk` (false = operar en modo local) y `pendientesSync` (registros de
* la cola SyncPendiente esperando réplica a Monday, incluidos los en ERROR).
*/
export async function estadoSistema(_req: Request, res: Response): Promise<void> {
const pendientesSync = await prisma.syncPendiente.count({
where: { estado: { in: ['PENDIENTE', 'ERROR'] } },
})
res.json({ mondayOk: saludMonday.estaDisponible(), pendientesSync })
}

View File

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidades: vi.fn(),
getUnidad: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { listarUnidades, obtenerUnidad } from './unidades.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const filaLocal = {
codigo: 'U-101',
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 70,
supExcedente: 0,
supEstacionamiento: 12.5,
precioLista: 2_000_000,
precioAcabados: 2_100_000,
status: 'DISPONIBLE',
etapa: 'Entrega inmediata',
}
function resFake() {
const json = vi.fn()
return { res: { json } as unknown as Response, json }
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
})
describe('unidades.controller (fallback local)', () => {
it('Monday vivo: devuelve Monday y no toca la tabla local', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(json).toHaveBeenCalledWith([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday caído (circuit breaker): lista desde la tabla Unidad con la misma forma de DTO', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
codigo: 'U-101',
modelo: '2RA',
precioSinAcabados: 2_000_000,
precioConAcabados: 2_100_000,
status: 'DISPONIBLE',
desarrollo: 'DIAGONAL',
etapa: 'Entrega inmediata',
})
})
it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findMany.mockResolvedValue([
filaLocal,
{ ...filaLocal, codigo: 'U-102', status: 'APARTADO' },
{ ...filaLocal, codigo: 'S-1', desarrollo: 'SENDA' },
])
const { res, json } = resFake()
await listarUnidades({ query: { disponibles: 'true', desarrollo: 'senda' } } as unknown as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista.map((u) => u['codigo'])).toEqual(['S-1'])
})
it('Monday lanza 502: fallback local; otro error se propaga', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as unknown[]).length).toBe(1)
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidades({ query: {} } as unknown as Request, resFake().res)).rejects.toThrow('bug')
})
it('obtenerUnidad: Monday caído → local; inexistente en ambos → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as Record<string, unknown>)['codigo']).toBe('U-101')
prismaMock.unidad.findUnique.mockResolvedValue(null)
await expect(
obtenerUnidad({ params: { codigo: 'U-X' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
})

View File

@@ -1,10 +1,13 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { NotFoundError } from '../utils/errors.js'
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo */
/**
* GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo.
* Monday caído (o 502) → espejo local (tabla Unidad), mismo contrato de respuesta.
*/
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await mondayService.getUnidades()
const unidades = await listarUnidadesConFallback()
const disponibles = req.query['disponibles'] === 'true'
const desarrollo = req.query['desarrollo']?.toString().toUpperCase()
let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades
@@ -14,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise<void>
res.json(lista)
}
/** GET /api/unidades/:codigo — 404 si no existe */
/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
const unidad = await obtenerUnidadConFallback(String(req.params['codigo']))
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
res.json(unidad)
}

View File

@@ -2,10 +2,44 @@ import cron from 'node-cron'
import { crearApp } from './app.js'
import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
import { mondayService } from './services/monday.service.js'
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
import { saludMonday } from './services/salud-monday.service.js'
import { syncMondayService } from './services/sync-monday.service.js'
// Registra los handlers de cada tipo de SyncPendiente sobre el worker (efecto lateral)
import './services/sync-handlers.js'
const app = crearApp()
// Wiring del circuit breaker: el ping vive en monday.service; se inyecta aquí
// para que salud-monday.service no lo importe (ciclo de imports).
saludMonday.registrarPing(() => mondayService.ping())
// Sondeo de salud de Monday (cada 60 s): tras FALLOS_PARA_CAER fallos GraphQL
// seguidos el flag cae y la app opera en modo local; cuando el ping vuelve a
// responder se recupera el flag y se drena la cola de sincronización.
// Corre siempre (aunque MONDAY_AUTOMATIONS_ENABLED=false): es la infra del modo local.
cron.schedule('* * * * *', () => {
void (async () => {
if (process.env.MODO_LOCAL_FORZADO === 'true') return // modo local forzado: no sondear
if (saludMonday.estaDisponible()) return // Monday sano: nada que hacer
const recuperado = await saludMonday.sondear()
if (recuperado && saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.log('[salud] Monday recuperado; drenando cola de sincronización')
void syncMondayService.procesarPendientes() // fire-and-forget (mutex interno)
}
})().catch((err: unknown) => console.error('[cron salud] error en sondeo:', err))
})
// Sync worker del modo local (cada 2 min): drena SyncPendiente FIFO hacia
// Monday. No-op mientras Monday esté caído. Secuencial por diseño (SQLite).
cron.schedule('*/2 * * * *', () => {
syncMondayService
.procesarPendientes()
.catch((err: unknown) => console.error('[cron sync] error en la pasada:', err))
})
// Crons de respaldo: los webhooks de Monday (registrados sobre
// https://frasa.consultoria-as.com/api/webhooks/monday) disparan estas mismas
// reglas en tiempo real; estos barridos quedan como red de seguridad por si un
@@ -18,6 +52,12 @@ if (config.automatizaciones.enabled) {
// escaneos en paralelo provoca timeouts P1008 en AutomatizacionState.
cron.schedule(config.automatizaciones.pollCron, () => {
void (async () => {
// Modo local: Monday caído → saltar las reglas sin ruido (fallan seguro de todos modos)
if (!saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.debug('[cron poll] Monday no disponible (modo local); pasada omitida')
return
}
try {
const r = await automatizacionesService.runRoundRobin()
if (r.asignados > 0) {
@@ -55,6 +95,11 @@ if (config.automatizaciones.enabled) {
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
cron.schedule(config.automatizaciones.recordatorioCron, () => {
if (!saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.debug('[cron inactividad] Monday no disponible (modo local); escaneo omitido')
return
}
automatizacionesService
.escaneoInactividad({ preview: false })
.then((r) => {

View File

@@ -81,6 +81,7 @@ import {
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js'
import { estadoSistema } from '../controllers/sistema.controller.js'
export const apiRouter = Router()
@@ -174,6 +175,9 @@ apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
// Estado del modo local (banner del front): salud de Monday + cola de sync
apiRouter.get('/sistema/estado', estadoSistema)
apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
updateMany: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { MondayService } from './monday.service.js'
import { saludMonday } from './salud-monday.service.js'
/**
* Fallback de actualizarDisponibilidadUnidad (modo local): con Monday caído o
* lanzando 502, el status se actualiza en el espejo local (tabla Unidad) y la
* réplica queda encolada (DISPONIBILIDAD_UNIDAD) para el sync worker.
*/
const crearServicio = (): MondayService =>
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
const respuesta500 = (): Response =>
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' })
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
vi.unstubAllGlobals()
saludMonday.restablecer()
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.syncPendiente.create.mockResolvedValue({})
})
describe('actualizarDisponibilidadUnidad (fallback local)', () => {
it('Monday caído (circuit breaker): actualiza el espejo local y encola DISPONIBILIDAD_UNIDAD sin fetch', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(fetchSpy).not.toHaveBeenCalled()
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: {
tipo: 'DISPONIBILIDAD_UNIDAD',
payloadJson: JSON.stringify({ codigo: 'U-101', status: 'APARTADA' }),
referenciaLocal: 'U-101',
},
})
})
it('Monday lanza 502 a media operación: mismo fallback local', async () => {
vi.stubGlobal('fetch', async () => respuesta500())
await crearServicio().actualizarDisponibilidadUnidad('U-102', 'VENDIDA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-102' },
data: { status: 'VENDIDA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledOnce()
})
it('unidad inexistente en el espejo local → 404 y no encola', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.updateMany.mockResolvedValue({ count: 0 })
await expect(crearServicio().actualizarDisponibilidadUnidad('U-X', 'APARTADA')).rejects.toThrow(
'Unidad U-X no encontrada',
)
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
it('un fallo al encolar no rompe la escritura local (best-effort)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.syncPendiente.create.mockRejectedValue(new Error('DB llena'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledOnce()
consoleSpy.mockRestore()
})
})

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { detalleContactoLocal, listarContactosLocal } from './contactos-local.service.js'
function clienteFake(parcial: Record<string, unknown> & { id: string }) {
return {
nombreCompleto: `Cliente ${parcial.id}`,
curp: 'CURP123',
rfc: 'RFC123',
email: null,
celular: null,
proyecto: '',
unidadCodigo: null,
payloadJson: '{}',
mondayContactoId: null,
mondayApartadoId: null,
capturadoPorId: null,
createdAt: new Date('2026-09-01T10:00:00'),
...parcial,
}
}
function capturaFake(parcial: Record<string, unknown> & { leadId: string }) {
return {
id: `row-${parcial.leadId}`,
nombreLead: `Persona ${parcial.leadId}`,
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
usuarioId: 'u-1',
datosJson: null,
mondayItemId: null,
pendienteSync: true,
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
...parcial,
}
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
})
describe('listarContactosLocal', () => {
it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con la forma de ContactoMonday + fuente local', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }),
])
const lista = await listarContactosLocal()
expect(lista).toHaveLength(2)
expect(lista[0]).toMatchObject({
id: 'loc_1', // updatedAt más reciente primero
nombre: 'ANA DEMO',
telefono: '6649998877',
estado: 'Pago Apartado',
fuente: 'local',
})
expect(lista[1]).toMatchObject({
id: 'c1',
nombre: 'JUAN PÉREZ',
telefono: '4771234567',
email: 'juan@correo.mx',
estado: 'Cliente',
fuente: 'local',
})
})
it('deduplica por teléfono (gana el Cliente: tiene la ficha completa)', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', celular: '+52 477 123 4567' }),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', telefono: '4771234567' }),
])
const lista = await listarContactosLocal()
expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('c1')
})
})
describe('detalleContactoLocal', () => {
it('null si el id no existe en ninguna tabla local', async () => {
expect(await detalleContactoLocal('nope')).toBeNull()
})
it('por cliente.id: contacto + propiedad + relaciones + historial derivado de lo local', async () => {
const cliente = clienteFake({
id: 'c1',
nombreCompleto: 'JUAN PÉREZ',
celular: '4771234567',
email: 'juan@correo.mx',
proyecto: 'SENDA',
unidadCodigo: 'SENDA-101',
mondayApartadoId: 'loc_a1',
capturadoPorId: 'u-1',
createdAt: new Date('2026-09-01T10:00:00'),
})
prismaMock.cliente.findFirst.mockResolvedValue(cliente)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado', telefono: '4771234567' }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Aparto', telefono: '4771234567', desarrollo: 'SENDA', modelo: '1R' }),
capturaFake({ leadId: 'loc_x', board: 'LEADS', etapa: 'NUEVO', telefono: '9990001111' }), // otro teléfono: fuera
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{
id: 'cot1',
unidadCodigo: 'SENDA-101',
tipoPropuesta: 'BANCARIO',
precioFinal: 4_400_000,
vendedorId: 'u-1',
createdAt: new Date('2026-08-30T10:00:00'),
},
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('c1')
expect(detalle).not.toBeNull()
if (detalle === null) return
expect(detalle.contacto).toMatchObject({
nombre: 'JUAN PÉREZ',
Teléfono: '4771234567',
Email: 'juan@correo.mx',
CURP: 'CURP123',
RFC: 'RFC123',
})
expect(detalle.propiedad).toMatchObject({
id: 'loc_a1',
unidadCodigo: 'SENDA-101',
desarrollo: 'SENDA',
estado: 'Pago Apartado',
precioFinal: 4_400_000,
atendidoPor: 'Martin Zavala',
})
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.relaciones.oportunidades[0]).toMatchObject({
id: 'loc_o1',
estado: 'Aparto',
atendidoPor: 'Martin Zavala',
detalle: 'SENDA · 1R',
})
expect(detalle.relaciones.apartados[0]).toMatchObject({ id: 'loc_a1', detalle: 'SENDA-101' })
expect(detalle.relaciones.leads).toHaveLength(0) // el de otro teléfono no entra
const detalles = detalle.historial.map((h) => h.detalle)
expect(detalles).toContain('Alta de cliente registrada (SENDA-101)')
expect(detalles).toContain('Documento subido (INE)')
expect(detalles).toContain('Cotización SENDA-101 (BANCARIO)')
expect(detalles.some((d) => d.startsWith('Captura en la app'))).toBe(true)
})
it('por leadId (loc_*): contacto desde la captura, sin propiedad', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Afluencia' }),
)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877' }),
])
const detalle = await detalleContactoLocal('loc_1')
expect(detalle).not.toBeNull()
if (detalle === null) return
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' })
expect(detalle.propiedad).toBeNull()
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,211 @@
import type { ContactoMonday } from './monday.service.js'
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
* Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está
* caído, la lista y el detalle se arman desde las tablas locales (Cliente +
* LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el
* camino Monday. Lo que no existe en local (activity log, columnas del board)
* va vacío; el historial se deriva de las capturas/altas/cotizaciones locales.
*/
/** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */
export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos).
*/
export async function listarContactosLocal(): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 })
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } },
orderBy: { updatedAt: 'desc' },
take: 100,
})
const vistos = new Set<string>()
const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => {
const clave = claveTelefono(entrada.telefono)
if (clave !== '') {
if (vistos.has(clave)) return
vistos.add(clave)
}
lista.push(entrada)
}
for (const c of clientes) {
agregar({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(),
fuente: 'local',
})
}
for (const c of capturas) {
agregar({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
updatedAt: c.updatedAt.toISOString(),
fuente: 'local',
})
}
return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100)
}
/**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* Monday sombra) o mondayContactoId. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email sobre LeadCaptura, igual que el camino Monday;
* el historial se deriva de capturas + alta + documentos + cotizaciones
* locales (no hay activity log en local). null si no existe en ninguna tabla.
*/
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
let cliente = await prisma.cliente.findFirst({
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
})
const capturaBase = cliente
? null
: await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
if (cliente === null && capturaBase === null) return null
const telefono = cliente?.celular ?? capturaBase?.telefono ?? ''
const email = (cliente?.email ?? capturaBase?.email ?? '').trim().toLowerCase()
const clave = claveTelefono(telefono)
// Si el id era de una captura, el Cliente ligado se busca por celular
if (cliente === null && clave !== '') {
cliente = await prisma.cliente.findFirst({
where: { celular: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
}
const capturasTodas = await prisma.leadCaptura.findMany({ orderBy: { createdAt: 'desc' } })
const relacionadas = capturasTodas.filter(
(c) =>
(clave !== '' && c.telefono !== null && claveTelefono(c.telefono) === clave) ||
(email !== '' && (c.email ?? '').trim().toLowerCase() === email),
)
// Cotizaciones y documentos locales del contacto
const cotizaciones =
clave !== ''
? await prisma.cotizacion.findMany({
where: { clienteTelefono: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
: []
const documentos = cliente
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
: []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...relacionadas.map((c) => c.usuarioId),
...(cliente?.capturadoPorId ? [cliente.capturadoPorId] : []),
...cotizaciones.map((c) => c.vendedorId).filter((id): id is string => id !== null),
]),
]
const usuarios = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
const nombreDe = (id: string | null): string | null => (id !== null ? (nombrePorId.get(id) ?? null) : null)
const unidadCliente = cliente?.unidadCodigo ?? cotizaciones[0]?.unidadCodigo ?? ''
const aRelacion = (c: (typeof relacionadas)[number], detalle: string): RelacionCrm => ({
id: c.leadId,
nombre: c.nombreLead,
estado: c.etapa,
atendidoPor: nombreDe(c.usuarioId),
detalle,
})
const relaciones = {
leads: relacionadas
.filter((c) => c.board === 'LEADS')
.map((c) => aRelacion(c, c.origen ?? '')),
oportunidades: relacionadas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => aRelacion(c, [c.desarrollo, c.modelo].filter((p) => p !== null && p !== '').join(' · '))),
apartados: relacionadas
.filter((c) => c.board === 'APARTADO')
.map((c) => aRelacion(c, c.leadId === cliente?.mondayApartadoId ? unidadCliente : '')),
}
// Propiedad: ficha del Cliente (la comprada o en proceso); sin Cliente no hay
let propiedad: DetalleContacto['propiedad'] = null
if (cliente !== null) {
const apartadoLocal = relacionadas.find((c) => c.leadId === cliente.mondayApartadoId)
propiedad = {
id: cliente.mondayApartadoId ?? cliente.id,
unidadCodigo: unidadCliente,
desarrollo: cliente.proyecto,
estado: apartadoLocal?.etapa ?? '',
precioFinal: cotizaciones[0]?.precioFinal ?? 0,
fechaCompra: cliente.createdAt.toISOString().slice(0, 10),
fechaLimite: '',
atendidoPor: nombreDe(cliente.capturadoPorId),
}
}
const historial: EventoHistorialContacto[] = [
...relacionadas.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.usuarioId),
detalle: `Captura en la app (${c.nombreLead})`,
fuente: 'App',
})),
...(cliente !== null
? [
{
fechaISO: cliente.createdAt.toISOString(),
usuario: nombreDe(cliente.capturadoPorId),
detalle: `Alta de cliente registrada${cliente.unidadCodigo !== null ? ` (${cliente.unidadCodigo})` : ''}`,
fuente: 'App',
},
]
: []),
...documentos.map((d) => ({
fechaISO: d.createdAt.toISOString(),
usuario: null,
detalle: `Documento subido (${d.tipo})`,
fuente: 'App',
})),
...cotizaciones.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.vendedorId),
detalle: `Cotización ${c.unidadCodigo} (${c.tipoPropuesta})`,
fuente: 'App',
})),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
const contacto: Record<string, string> =
cliente !== null
? {
nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '',
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
Estado: 'Cliente',
}
: {
nombre: capturaBase!.nombreLead,
Teléfono: capturaBase!.telefono ?? '',
Email: capturaBase!.email ?? '',
Estado: capturaBase!.etapa,
}
return { contacto, propiedad, relaciones, historial }
}

View File

@@ -0,0 +1,368 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
cliente: {
findMany: vi.fn(),
},
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { CrmLocalService, esIdLocal } from './crm-local.service.js'
const svc = new CrmLocalService()
/** Tipos de sync encolados, en orden, a partir de las llamadas al mock */
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
function payloadEncolado(tipo: string): Record<string, unknown> {
const llamada = prismaMock.syncPendiente.create.mock.calls.find(
(c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo,
)
if (!llamada) throw new Error(`No se encoló ${tipo}`)
return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record<string, unknown>
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'row-nueva', ...data }))
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // default: sin duplicados
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
describe('esIdLocal', () => {
it('distingue ids locales de ids de Monday', () => {
expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true)
expect(esIdLocal('1234567890')).toBe(false)
})
})
describe('CrmLocalService.crearLead', () => {
it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => {
const r = await svc.crearLead(
{ nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' },
'u-1',
)
expect('duplicado' in r).toBe(false)
if ('duplicado' in r) return
expect(r.id).toMatch(/^loc_/)
expect(r.oportunidadId).toBeNull()
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
leadId: r.id,
usuarioId: 'u-1',
nombreLead: 'Ana Prueba',
board: 'LEADS',
etapa: 'NUEVO',
origen: 'WhatsApp',
desarrollo: 'SENDA',
pendienteSync: true,
}),
})
expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA'])
const payload = payloadEncolado('CREAR_LEAD')
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' })
expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' })
// Todo quedó en una sola transacción (cambio local + cola atómicos)
expect(prismaMock.$transaction).toHaveBeenCalledOnce()
})
it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Ana Prueba' })
const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' })
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})
describe('CrmLocalService.crearAfluencia', () => {
it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => {
const r = await svc.crearAfluencia(
{
nombre: 'Luis Demo',
telefono: '6641112233',
desarrollo: 'ATELIER',
modelo: 'SOHO',
edad: 35,
credito: 'Infonavit',
loQuierePara: 'Habitarla',
},
'u-2',
)
expect('duplicado' in r).toBe(false)
if ('duplicado' in r) return
expect(r.id).toMatch(/^loc_/)
expect(r.oportunidadId).toBe(r.id) // la afluencia ES la oportunidad
const creada = prismaMock.leadCaptura.create.mock.calls[0]?.[0] as { data: Record<string, unknown> }
expect(creada.data).toMatchObject({
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
modelo: 'SOHO',
pendienteSync: true,
})
// Perfilamiento completo en datosJson
expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' })
// Auto-mapping leadId = oportunidadId (como el flujo Monday)
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } })
expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA'])
expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({
nombre: 'Luis Demo',
modeloLabel: 'SOHO',
creditoLabel: 'Infonavit',
loQuierePara: 'Habitarla',
})
})
it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' })
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2')
expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'op-55' },
data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }),
})
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA'])
expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' })
})
it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_op', nombreLead: 'Luis Demo' })
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '6641112233' })
expect(r).toEqual({ duplicado: { id: 'loc_op', nombre: 'Luis Demo' } })
})
})
describe('CrmLocalService.cambiarEstado', () => {
it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Agendo Cita', pendienteSync: true },
})
expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' })
})
it('sin fila local y sin sombra → 404', async () => {
await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 })
})
it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => {
await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' })
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
})
})
describe('CrmLocalService.descartar', () => {
it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' })
await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true },
})
expect(payloadEncolado('DESCARTE')).toEqual({
fuente: 'oportunidad',
motivo: 'Precio',
nombreItem: 'Luis Demo',
subcategoria: 'Enganche',
comentario: 'Se le hizo caro',
})
})
it('fuente lead: etapa terminal Descartado', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_3', nombreLead: 'Ana' })
await svc.descartar('loc_3', 'Timing', { fuente: 'lead' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_3' },
data: expect.objectContaining({ etapa: 'Descartado' }),
})
})
})
describe('CrmLocalService.convertirLead', () => {
const filaLead = {
leadId: 'loc_lead',
oportunidadId: null,
usuarioId: 'u-1',
nombreLead: 'Ana Prueba',
telefono: '4771234567',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: null,
datosJson: '{"notas":null}',
}
it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead)
const op = await svc.convertirLead('loc_lead', 'u-1')
expect(op.leadId).toMatch(/^loc_/)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_lead' },
data: { etapa: 'Calificado', oportunidadId: op.leadId, pendienteSync: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
leadId: op.leadId,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
nombreLead: 'Ana Prueba',
pendienteSync: true,
}),
})
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'loc_lead', oportunidadId: op.leadId },
})
const payload = payloadEncolado('CONVERSION')
expect(payload['oportunidadIdLocal']).toBe(op.leadId)
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' })
})
it('si el lead ya fue convertido → 409 como el flujo Monday', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' })
await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('lead inexistente → 404', async () => {
await expect(svc.convertirLead('loc_fantasma')).rejects.toMatchObject({ status: 404 })
})
})
describe('CrmLocalService.apartar', () => {
const filaOportunidad = {
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: '6641112233',
email: 'luis@correo.com',
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: '{"edad":35}',
}
it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toMatch(/^loc_/)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { etapa: 'Aparto', pendienteSync: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
board: 'APARTADO',
etapa: 'Pago Apartado',
nombreLead: 'Luis Demo',
pendienteSync: true,
}),
})
// Dedup compatible con las automatizaciones
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }),
)
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({
clienteNombre: 'Luis Demo',
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'luis@correo.com',
})
})
it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => {
prismaMock.leadCaptura.findUnique.mockImplementation(async ({ where }: { where: { leadId: string } }) =>
where.leadId === 'loc_op' ? filaOportunidad : { leadId: 'loc_ap_previo', board: 'APARTADO' },
)
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:loc_op', valor: 'loc_ap_previo' })
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toBe('loc_ap_previo')
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})
describe('CrmLocalService.buscarLocales', () => {
it('combina LeadCaptura y Cliente con la forma del picker del alta', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_1',
nombreLead: 'Ana Prueba',
telefono: '4771234567',
email: null,
etapa: 'Afluencia',
origen: 'WhatsApp',
board: 'OPORTUNIDADES',
desarrollo: 'SENDA',
modelo: 'SOHO',
updatedAt: new Date('2026-09-05T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([
{
id: 'cli-9',
nombreCompleto: 'Ana Cliente',
celular: '6641112233',
email: 'ana@correo.com',
proyecto: 'ATELIER',
createdAt: new Date('2026-09-04T10:00:00Z'),
},
])
const r = await svc.buscarLocales('ana')
expect(r).toHaveLength(2)
// Más reciente primero: la captura (sep-05) antes que el cliente (sep-04)
expect(r[0]).toEqual({
id: 'loc_1',
nombre: 'Ana Prueba',
telefono: '4771234567',
email: '',
estado: 'Afluencia',
origen: 'WhatsApp',
fuente: 'oportunidad',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
})
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }),
)
})
})

View 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()

View File

@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import {
resumenMiDiaLocal,
unirPendientesLocales,
type ClienteLocalMiDia,
type FilaLocalMiDia,
type ResumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
/**
* Mi día en modo local (Monday caído) y la unión durante la transición
* (Monday vivo + capturas locales pendientes de sincronizar).
*/
const AHORA = new Date('2026-09-05T12:00:00')
const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: 123 }
const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor', mondayUserId: null }
let consecutivo = 0
function fila(parcial: Partial<FilaLocalMiDia> & { leadId: string }): FilaLocalMiDia {
consecutivo += 1
return {
nombreLead: `Persona ${parcial.leadId}`,
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
mondayItemId: null,
pendienteSync: true,
createdAt: new Date(AHORA.getTime() - consecutivo * 3_600_000),
updatedAt: new Date(AHORA.getTime() - consecutivo * 1_800_000),
...parcial,
}
}
function cliente(parcial: Partial<ClienteLocalMiDia> & { id: string }): ClienteLocalMiDia {
return {
nombreCompleto: `Cliente ${parcial.id}`,
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-09-04T10:00:00'),
...parcial,
}
}
describe('resumenMiDiaLocal (Monday caído)', () => {
it('arma el ResumenMiDia completo desde LeadCaptura por board, sin terminales', () => {
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO' }),
fila({ leadId: 'loc_l2', board: 'LEADS', etapa: 'Descartado' }), // terminal: fuera
fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', desarrollo: 'SENDA', modelo: '1R' }),
fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', etapa: 'Perdido' }), // terminal: fuera de listas, cuenta en porEtapa
fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' }),
fila({ leadId: 'loc_a2', board: 'APARTADO', etapa: 'Cancelado' }), // final: fuera
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id).sort()).toEqual(['loc_l1', 'loc_o1'])
expect(r.misLeads.find((l) => l.id === 'loc_l1')?.fuente).toBe('lead')
expect(r.misLeads.find((l) => l.id === 'loc_o1')?.fuente).toBe('oportunidad')
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 1, Perdido: 1 })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1'])
expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1'])
expect(r.misCapturas).toHaveLength(6)
// Lo que no existe en local va vacío/cero sin romper el front
expect(r.misActividadesHoy).toEqual([])
expect(r.apartadosPorVencer).toEqual([])
expect(r.conteos.actividadesAbiertas).toBe(0)
expect(r.equipo).toBeUndefined()
})
it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => {
const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toEqual({
fechaISO: '2026-09-05T08:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
expect(rSinNombre.sinMapeoMonday).toBe(true)
})
it('la unidad del apartado sale del Cliente ligado; estadoApartado de Mis clientes cruza con el apartado local', () => {
const capturas = [fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' })]
const clientes = [
cliente({ id: 'c1', mondayApartadoId: 'loc_a1', unidadCodigo: 'SENDA-101', proyecto: 'SENDA' }),
cliente({ id: 'c2' }),
]
const r = resumenMiDiaLocal(vendedor, capturas, clientes, AHORA)
expect(r.misApartados[0]?.unidadCodigo).toBe('SENDA-101')
const c1 = r.misClientes.find((c) => c.id === 'c1')
expect(c1).toMatchObject({ completado: true, estadoApartado: 'Pago Apartado' })
expect(r.misClientes.find((c) => c.id === 'c2')?.estadoApartado).toBeNull()
})
it('leadsNuevosSemana cuenta capturas de LEADS + OPORTUNIDADES de los últimos 7 días', () => {
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', createdAt: new Date('2026-09-04T00:00:00') }),
fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', createdAt: new Date('2026-09-01T00:00:00') }),
fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', createdAt: new Date('2026-08-20T00:00:00') }), // > 7 días
fila({ leadId: 'loc_a1', board: 'APARTADO', createdAt: new Date('2026-09-04T00:00:00') }), // apartado no cuenta
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
})
// ---------- Unión (Monday vivo + pendientes locales) ----------
function resumenBase(): ResumenMiDia {
return {
generadoEn: AHORA.toISOString(),
sinMapeoMonday: false,
misLeads: [
{
id: '9001',
nombre: 'Lead Monday',
telefono: '',
email: '',
estado: 'Nuevo',
origen: 'Web',
updatedAt: '2026-09-04T10:00:00.000Z',
fuente: 'lead',
ultimoCambio: null,
},
],
misCapturas: [
{ leadId: 'loc_n1', nombre: 'Cap Local', telefono: '', estado: '', origen: '', capturadoEn: '2026-09-03T10:00:00.000Z' },
{ leadId: '9001', nombre: 'Lead Monday', telefono: '', estado: 'Nuevo', origen: 'Web', capturadoEn: '2026-09-02T10:00:00.000Z' },
],
misClientes: [],
misActividadesHoy: [],
apartadosPorVencer: [],
misOportunidades: {
porEtapa: { Afluencia: 1 },
items: [
{
id: '9002',
nombre: 'Op Monday',
telefono: '',
email: '',
etapa: 'Afluencia',
desarrollo: '',
modeloInteres: '',
unidadCodigo: '',
updatedAt: '2026-09-04T09:00:00.000Z',
ultimoCambio: null,
},
],
},
misApartados: [
{ id: '9003', nombre: 'Apt Monday', unidadCodigo: 'U-1', estado: 'Pago Apartado', fechaLimite: '2026-09-10', ultimoCambio: null },
],
conteos: { leadsNuevosSemana: 1, actividadesAbiertas: 0 },
}
}
describe('unirPendientesLocales (Monday vivo)', () => {
it('agrega los items loc_* a la lista de su board con pendienteSync y re-ordena', () => {
const pendientes = [
fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', updatedAt: new Date('2026-09-05T11:00:00') }),
fila({ leadId: 'loc_n2', board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-09-05T10:00:00') }),
fila({ leadId: 'loc_n3', board: 'APARTADO', etapa: 'Pago Apartado' }),
fila({ leadId: 'loc_n4', board: 'LEADS', etapa: 'Descartado' }), // terminal: no se agrega
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
// loc_n1 es más reciente que el de Monday: queda primero
expect(r.misLeads.map((l) => l.id)).toEqual(['loc_n1', 'loc_n2', '9001'])
expect(r.misLeads[0]).toMatchObject({ pendienteSync: true, fuente: 'lead' })
expect(r.misLeads[1]).toMatchObject({ pendienteSync: true, fuente: 'oportunidad' })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_n2', '9002'])
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2 })
expect(r.misApartados.map((a) => a.id)).toEqual(['9003', 'loc_n3']) // sin fecha límite al final
expect(r.misApartados[1]).toMatchObject({ pendienteSync: true })
})
it('fila con mondayItemId (cambio pendiente sobre item Monday): no duplica, solo marca el existente', () => {
const pendientes = [
fila({ leadId: '9001', board: 'LEADS', etapa: 'Contactado', mondayItemId: '9001' }),
fila({ leadId: '9003', board: 'APARTADO', etapa: 'Documentos', mondayItemId: '9003' }),
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
expect(r.misLeads).toHaveLength(1)
expect(r.misLeads[0]).toMatchObject({ id: '9001', pendienteSync: true })
expect(r.misApartados).toHaveLength(1)
expect(r.misApartados[0]).toMatchObject({ id: '9003', pendienteSync: true })
})
it('marca Mis capturas y rellena el estado local cuando Monday aún no lo tiene', () => {
const pendientes = [fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp' })]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
const captura = r.misCapturas.find((c) => c.leadId === 'loc_n1')
expect(captura).toMatchObject({ pendienteSync: true, estado: 'NUEVO', origen: 'WhatsApp' })
// La captura ya conocida por Monday conserva su estado
expect(r.misCapturas.find((c) => c.leadId === '9001')?.estado).toBe('Nuevo')
})
})

View File

@@ -52,6 +52,8 @@ export interface LeadMiDia {
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ActividadMiDia {
@@ -78,6 +80,8 @@ export interface CapturaMiDia {
estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board
origen: string
capturadoEn: string // ISO
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ClienteMiDia {
@@ -104,6 +108,8 @@ export interface OportunidadMiDia {
updatedAt: string
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
@@ -115,6 +121,8 @@ export interface ApartadoMiDia {
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ResumenMiDia {
@@ -499,3 +507,291 @@ export async function resumenMiDia(
return resumen
}
// ---------- Modo local (Monday caído) y unión durante la transición ----------
/**
* Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El
* controller pasa las filas de Prisma ya filtradas por el usuario; la forma es
* estructural para que los tests usen fixtures llanas.
*/
export interface FilaLocalMiDia {
leadId: string
nombreLead: string
telefono: string | null
email: string | null
origen: string | null
desarrollo: string | null
modelo: string | null
board: string // LEADS | OPORTUNIDADES | APARTADO
etapa: string
/** Item de Monday una vez sincronizado (null en capturas puramente locales) */
mondayItemId: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
}
/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */
export interface ClienteLocalMiDia {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}
/** "Último cambio" derivado del updatedAt local; el quién es el capturista ('app' si no hay nombre) */
function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) {
return {
fechaISO: fila.updatedAt.toISOString(),
usuario: nombreUsuario.trim() !== '' ? nombreUsuario : 'app',
detalle: 'Actualización',
}
}
const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number =>
b.updatedAt.localeCompare(a.updatedAt)
/**
* ResumenMiDia COMPLETO construido solo con datos locales (Monday caído).
* Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal),
* Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO +
* unidad ligada vía Cliente), Mis capturas, Mis clientes. Lo que no existe en
* local va vacío/cero sin romper el front: actividades del día (no hay tabla
* local), apartadosPorVencer (los apartados locales aún no guardan fecha
* límite) y la sección equipo (se omite).
*/
export function resumenMiDiaLocal(
usuario: UsuarioMiDia,
capturas: FilaLocalMiDia[],
clientes: ClienteLocalMiDia[],
ahora: Date = new Date(),
): ResumenMiDia {
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
// "Mis leads" mezcla Leads + Oportunidades, igual que el camino Monday
const misLeads: LeadMiDia[] = [
...leadsLocales
.filter((c) => !esEstadoTerminalLead(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
updatedAt: c.updatedAt.toISOString(),
fuente: 'lead' as const,
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
})),
...oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
updatedAt: c.updatedAt.toISOString(),
fuente: 'oportunidad' as const,
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
})),
].sort(porUpdatedAtDesc)
// Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday)
const porEtapa: Record<string, number> = {}
for (const o of oportunidadesLocales) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
unidadCodigo: '',
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
.sort(porUpdatedAtDesc)
// La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada)
const unidadPorApartadoId = new Map(
clientes
.filter((c) => c.mondayApartadoId !== null)
.map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']),
)
const misApartados: ApartadoMiDia[] = apartadosLocales
.filter((c) => !esEstadoFinalApartado(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: '',
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => ({
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: c.etapa,
origen: c.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
pendienteSync: c.pendienteSync,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
proyecto: c.proyecto,
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
estadoApartado:
c.mondayApartadoId != null ? (etapaApartadoPorId.get(c.mondayApartadoId) ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
return {
generadoEn: ahora.toISOString(),
sinMapeoMonday: usuario.mondayUserId === null,
misLeads,
misCapturas,
misClientes,
misActividadesHoy: [], // no hay tabla local de actividades
apartadosPorVencer: [], // los apartados locales aún no guardan fecha límite
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,
).length,
actividadesAbiertas: 0,
},
}
}
/**
* Unión durante la transición (Monday vivo): agrega a las listas del resumen
* los items locales con pendienteSync=true que Monday aún no conoce (ids
* loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila
* local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se
* duplica: solo se marca el item existente. También marca/rellena Mis capturas
* (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`.
*/
export function unirPendientesLocales(
resumen: ResumenMiDia,
pendientes: FilaLocalMiDia[],
nombreUsuario: string,
): ResumenMiDia {
const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o]))
const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a]))
const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c]))
for (const p of pendientes) {
// Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja
const captura = capturasPorId.get(p.leadId)
if (captura) {
captura.pendienteSync = true
if (captura.estado === '') captura.estado = p.etapa
if (captura.origen === '') captura.origen = p.origen ?? ''
}
if (p.mondayItemId !== null) {
// Cambio pendiente sobre un item que Monday ya muestra: solo se marca
const existente =
leadsPorId.get(p.mondayItemId) ??
oportunidadesPorId.get(p.mondayItemId) ??
apartadosPorId.get(p.mondayItemId)
if (existente) existente.pendienteSync = true
continue
}
// Item puramente local (loc_*): se agrega a la lista de su board
if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'lead',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
} else if (p.board === 'OPORTUNIDADES') {
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim()
resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1
if (!esEtapaTerminal(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'oportunidad',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
resumen.misOportunidades.items.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
etapa: p.etapa,
desarrollo: p.desarrollo ?? '',
modeloInteres: p.modelo ?? '',
unidadCodigo: '',
updatedAt: p.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
}
} else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) {
resumen.misApartados.push({
id: p.leadId,
nombre: p.nombreLead,
unidadCodigo: '',
estado: p.etapa,
fechaLimite: '',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
}
}
resumen.misLeads.sort(porUpdatedAtDesc)
resumen.misOportunidades.items.sort(porUpdatedAtDesc)
resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
return resumen
}

View File

@@ -1,6 +1,8 @@
import { config } from '../config/index.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { AppError } from '../utils/errors.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
/** Unidad tal como la consume el API (y el frontend del cotizador) */
export type Unidad = UnidadCotizable
@@ -230,12 +232,19 @@ export class MondayService {
for (let intento = 0; intento <= this.maxReintentos; intento++) {
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
try {
return await this.graphqlOnce<T>(query, variables)
const data = await this.graphqlOnce<T>(query, variables)
saludMonday.registrarExito()
return data
} catch (err) {
if (!(err instanceof MondayRetryableError)) throw err
if (!(err instanceof MondayRetryableError)) {
// Fallo definitivo (AppError 502 de Monday): alimenta el circuit breaker
saludMonday.registrarFallo()
throw err
}
ultimoError = err
}
}
saludMonday.registrarFallo()
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
}
@@ -363,9 +372,45 @@ export class MondayService {
unidades.push(unidad)
}
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
// Write-through al espejo local (tabla Unidad): mantiene fresco el
// inventario para el modo local. Best-effort: nunca rompe la lectura.
void this.persistirUnidadesLocal(unidades)
return unidades
}
/**
* Upsert best-effort de las unidades leídas de Monday a la tabla local
* `Unidad` (fallback del cotizador cuando Monday cae). Protegido para poder
* apagarlo en tests; los errores solo se loguean.
*/
protected async persistirUnidadesLocal(unidades: Unidad[]): Promise<void> {
// Los tests mockean fetch y no deben escribir en la base real
if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return
try {
for (const u of unidades) {
const datos = {
desarrollo: u.desarrollo,
modelo: u.modelo,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
supEstacionamiento: u.supEstacionamiento,
precioLista: u.precioSinAcabados,
precioAcabados: u.precioConAcabados,
status: u.status,
etapa: u.etapa ?? '',
}
await prisma.unidad.upsert({
where: { codigo: u.codigo },
update: datos,
create: { codigo: u.codigo, ...datos },
})
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[unidades] falló el write-through local (se ignora):', err)
}
}
/** Una unidad por código (U-101, U-310...); null si no existe */
async getUnidad(codigo: string): Promise<Unidad | null> {
const unidades = await this.getUnidades()
@@ -724,19 +769,60 @@ export class MondayService {
}
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
// Modo local: Monday caído (circuit breaker) → espejo local + réplica encolada
if (!saludMonday.estaDisponible()) {
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
return
}
try {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
} catch (err) {
// Monday se cayó a media operación: mismo fallback local (el 404 de
// "no existe en Monday" NO es fallo de Monday y se propaga intacto)
if (err instanceof AppError && err.status === 502) {
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
return
}
throw err
}
}
/**
* Fallback local de actualizarDisponibilidadUnidad: actualiza el status en el
* espejo (tabla Unidad) y encola la réplica DISPONIBILIDAD_UNIDAD para que el
* sync worker la aplique en Monday cuando vuelva. 404 si la unidad no está en
* el espejo. El encolado es best-effort (nunca rompe la escritura local).
*/
private async actualizarDisponibilidadUnidadLocal(
codigo: string,
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA',
): Promise<void> {
const resultado = await prisma.unidad.updateMany({ where: { codigo }, data: { status } })
if (resultado.count === 0) throw new AppError(404, `Unidad ${codigo} no encontrada`)
try {
await prisma.syncPendiente.create({
data: {
tipo: 'DISPONIBILIDAD_UNIDAD',
payloadJson: JSON.stringify({ codigo, status }),
referenciaLocal: codigo,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[sync] no se pudo encolar DISPONIBILIDAD_UNIDAD de ${codigo}:`, err)
}
}
// ---------- Escrituras genéricas ----------

View File

@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PlanPago } from './cobranza.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findMany: vi.fn(),
},
unidad: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js'
/**
* Fallback de reportes (modo local): Monday caído o 502 → el resumen se
* calcula desde tablas locales (LeadCaptura por board, Unidad por status,
* PlanPago como siempre) con el flag aditivo `parcial: true`.
*/
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] }
}
function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
return {
id,
clienteNombre: `Cliente ${id}`,
unidadCodigo: 'U-101',
proyecto: 'ATELIER',
fechaInicio: '2026-07-01',
engancheTotal: 100000,
numPagos: 2,
mensualidad: 50000,
status: 'ACTIVO',
pagos: [pago(`p-${id}`, '2027-01-01', 50000)], // pendiente y al corriente: ACTIVO
notas: [],
createdAt: '2026-07-01T00:00:00.000Z',
...overrides,
}
}
function leadMonday(id: string, estado = 'Nuevo'): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-09-04T10:00:00.000Z',
updatedAt: '2026-09-04T10:00:00.000Z',
estado,
origen: 'Web',
telefono: '',
email: '',
vendedorIds: [],
}
}
function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad {
return {
codigo,
modelo: '1R',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 1_000_000,
precioConAcabados: 1_100_000,
status,
desarrollo: 'SENDA',
mesesParaFirma: null,
etapa: null,
}
}
function mondayMock(overrides: Record<string, unknown> = {}) {
return {
getLeads: vi.fn(async () => [leadMonday('m1')]),
getOportunidades: vi.fn(async () => [] as OportunidadMonday[]),
getUnidades: vi.fn(async () => [unidadMonday('M-1')]),
getActividadesAbiertas: vi.fn(async () => 3),
...overrides,
}
}
const datosLocales: DatosLocalesReportes = {
leads: [leadMonday('loc_l1'), leadMonday('loc_l2', 'Descartado')],
oportunidades: [
{
id: 'loc_o1',
nombre: 'Op Local',
createdAt: '2026-09-04T10:00:00.000Z',
updatedAt: '2026-09-04T10:00:00.000Z',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
origen: 'WhatsApp',
desarrollo: 'SENDA',
modeloInteres: '1R',
credito: 'Infonavit',
},
],
unidades: [unidadMonday('U-1'), unidadMonday('U-2', 'APARTADO')],
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
})
describe('ReportesService (fallback local)', () => {
it('Monday caído (salud): resumen desde datos locales con parcial: true, sin llamar a Monday', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a')], {
salud: { estaDisponible: () => false },
cargarDatosLocales: async () => datosLocales,
})
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.leads.total).toBe(2)
expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 })
expect(r.oportunidades.total).toBe(1)
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 })
expect(r.unidades.total).toBe(2)
expect(r.unidades.disponibles).toBe(1)
expect(r.cobranza.planes.activos).toBe(1) // planes locales como siempre
expect(r.actividadesAbiertas).toBe(0) // no hay tabla local de actividades
expect(monday.getLeads).not.toHaveBeenCalled()
})
it('Monday vivo: camino normal intacto (sin parcial) y cache de 60 s', async () => {
const monday = mondayMock()
const cargarPlanes = vi.fn(async () => [plan('a')])
let ahora = 1_000_000
const svc = new ReportesService(monday, cargarPlanes, {
now: () => ahora,
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
const r1 = await svc.resumen()
expect(r1.parcial).toBeUndefined()
expect(r1.leads.total).toBe(1)
expect(r1.actividadesAbiertas).toBe(3)
ahora += 30_000 // dentro del TTL: segunda llamada sale del cache
const r2 = await svc.resumen()
expect(r2).toBe(r1)
expect(monday.getLeads).toHaveBeenCalledTimes(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
})
it('Monday lanza AppError 502 a media lectura: cae al resumen local con parcial', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(502, 'Monday no respondió') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.leads.total).toBe(2)
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(500, 'bug') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
await expect(svc.resumen()).rejects.toThrow('bug')
})
})
describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs Monday)', () => {
it('mapea boards a LeadMonday/OportunidadMonday con el perfil de datosJson y unidades al DTO', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_l1',
nombreLead: 'Lead Local',
telefono: '4771234567',
email: null,
origen: 'Web',
desarrollo: null,
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: null,
createdAt: new Date('2026-09-04T10:00:00'),
updatedAt: new Date('2026-09-04T11:00:00'),
},
{
leadId: 'loc_o1',
nombreLead: 'Op Local',
telefono: null,
email: 'op@correo.mx',
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify({ estadoCivil: 'Soltero(a)', tipoEmpleo: 'Empleado', credito: 'Infonavit', edad: 35, califica: 'Si' }),
createdAt: new Date('2026-09-03T10:00:00'),
updatedAt: new Date('2026-09-03T11:00:00'),
},
{
leadId: 'loc_a1',
nombreLead: 'Apt Local',
board: 'APARTADO', // el board APARTADO no alimenta leads ni oportunidades
etapa: 'Pago Apartado',
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
datosJson: null,
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-02T11:00:00'),
},
])
prismaMock.unidad.findMany.mockResolvedValue([
{
codigo: 'SENDA-101',
desarrollo: 'SENDA',
modelo: '1R',
supConstruccion: 69.5,
supExcedente: 0,
supEstacionamiento: 12.5,
precioLista: 4_415_000,
precioAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: '',
},
])
const datos = await cargarDatosLocalesPrisma()
expect(datos.leads).toHaveLength(1)
expect(datos.leads[0]).toMatchObject({ id: 'loc_l1', estado: 'NUEVO', origen: 'Web', telefono: '4771234567' })
expect(datos.oportunidades).toHaveLength(1)
expect(datos.oportunidades[0]).toMatchObject({
id: 'loc_o1',
etapa: 'Afluencia',
desarrollo: 'SENDA',
modeloInteres: '1R',
estadoCivil: 'Soltero(a)',
tipoEmpleo: 'Empleado',
credito: 'Infonavit',
edad: 35,
califica: 'Si',
})
expect(datos.unidades).toHaveLength(1)
expect(datos.unidades[0]).toMatchObject({
codigo: 'SENDA-101',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: null,
})
})
})

View File

@@ -1,6 +1,10 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
import { unidadLocalADto } from './unidades-local.service.js'
import { AppError } from '../utils/errors.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
@@ -73,6 +77,8 @@ export interface ResumenReportes {
montoVencido: number
}
actividadesAbiertas: number
/** true cuando el resumen se calculó desde tablas locales (Monday caído): métricas parciales */
parcial?: boolean
}
/** Datos crudos que necesita el agregador (inyectables en tests) */
@@ -285,16 +291,102 @@ type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'get
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
/** Datos del embudo/inventario leídos de tablas locales (modo degradado) */
export interface DatosLocalesReportes {
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
}
/** Cargador de los datos locales para el fallback (Prisma en prod, fixture en tests) */
export type CargadorDatosLocales = () => Promise<DatosLocalesReportes>
export interface OpcionesReportes {
/** Reloj inyectable para probar el cache (default Date.now) */
now?: () => number
/** TTL del cache en ms (default 60 s) */
cacheMs?: number
/** Salud de Monday (default: singleton saludMonday) */
salud?: Pick<SaludMondayService, 'estaDisponible'>
/** Datos locales para el fallback (default: tablas LeadCaptura/Unidad vía Prisma) */
cargarDatosLocales?: CargadorDatosLocales
}
/** string de un campo del datosJson de afluencia ('' si no es string) */
function campoTexto(datos: Record<string, unknown>, campo: string): string {
const v = datos[campo]
return typeof v === 'string' ? v : ''
}
/**
* Cargador local por defecto: leads/oportunidades desde LeadCaptura (board +
* etapa; el perfil de la afluencia va en datosJson) e inventario desde la
* tabla Unidad (espejo). Misma forma de DTO que las lecturas Monday.
*/
export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes> {
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } },
})
const unidadesRows = await prisma.unidad.findMany()
const leads: LeadMonday[] = capturas
.filter((c) => c.board === 'LEADS')
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
estado: c.etapa,
origen: c.origen ?? '',
telefono: c.telefono ?? '',
email: c.email ?? '',
vendedorIds: [],
}))
const oportunidades: OportunidadMonday[] = capturas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => {
let datos: Record<string, unknown> = {}
if (c.datosJson !== null) {
try {
datos = JSON.parse(c.datosJson) as Record<string, unknown>
} catch {
datos = {}
}
}
const edad = datos['edad']
return {
id: c.leadId,
nombre: c.nombreLead,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
etapa: c.etapa,
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
medioContacto: campoTexto(datos, 'medioContacto'),
edad: typeof edad === 'number' && Number.isFinite(edad) ? edad : null,
estadoCivil: campoTexto(datos, 'estadoCivil'),
tipoEmpleo: campoTexto(datos, 'tipoEmpleo'),
credito: campoTexto(datos, 'credito'),
tipoEconomia: campoTexto(datos, 'tipoEconomia'),
califica: campoTexto(datos, 'califica'),
rangoSalarial: campoTexto(datos, 'rangoSalarial'),
loQuierePara: campoTexto(datos, 'loQuierePara'),
}
})
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto) }
}
export class ReportesService {
private readonly now: () => number
private readonly cacheMs: number
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
private readonly cargarDatosLocales: CargadorDatosLocales
private cache: { resumen: ResumenReportes; expira: number } | null = null
constructor(
@@ -304,6 +396,8 @@ export class ReportesService {
) {
this.now = opciones.now ?? Date.now
this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS
this.salud = opciones.salud ?? saludMonday
this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma
}
/** Invalida el cache (útil tras escrituras que cambian los números) */
@@ -315,16 +409,35 @@ export class ReportesService {
const ahoraMs = this.now()
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = await this.cargarResumen(ahoraMs)
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}
/**
* Camino normal: Monday + planes locales en paralelo (con el cache de 60 s
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
*/
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> {
if (this.salud.estaDisponible()) {
try {
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
} catch (err) {
if (!(err instanceof AppError && err.status === 502)) throw err
// Monday se cayó a media lectura: cae al camino local
}
}
const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()])
const resumen = construirResumen({ ...locales, planes, actividadesAbiertas: 0 }, new Date(ahoraMs))
return { ...resumen, parcial: true }
}
}

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from 'vitest'
import { FALLOS_PARA_CAER, SaludMondayService } from './salud-monday.service.js'
describe('SaludMondayService', () => {
afterEach(() => {
delete process.env.MODO_LOCAL_FORZADO
})
it('arranca disponible (optimista)', () => {
const salud = new SaludMondayService()
expect(salud.ok()).toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('cae tras FALLOS_PARA_CAER fallos consecutivos', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER - 1; i++) salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // aún aguanta
salud.registrarFallo()
expect(salud.ok()).toBe(false)
expect(salud.estaDisponible()).toBe(false)
})
it('un éxito resetea el contador de fallos', () => {
const salud = new SaludMondayService()
salud.registrarFallo()
salud.registrarFallo()
salud.registrarExito()
salud.registrarFallo()
salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // nunca llegó a 3 seguidos
})
it('un éxito recupera el flag una vez caído', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
expect(salud.ok()).toBe(false)
salud.registrarExito()
expect(salud.estaDisponible()).toBe(true)
})
it('MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday esté sano', () => {
const salud = new SaludMondayService()
process.env.MODO_LOCAL_FORZADO = 'true'
expect(salud.ok()).toBe(true) // el flag crudo no cambia
expect(salud.estaDisponible()).toBe(false)
process.env.MODO_LOCAL_FORZADO = 'false'
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping exitoso recupera el flag y devuelve true', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => true)
await expect(salud.sondear()).resolves.toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping fallido deja el flag caído', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => false)
await expect(salud.sondear()).resolves.toBe(false)
expect(salud.ok()).toBe(false)
})
it('sondear() sin ping registrado devuelve el flag actual', async () => {
const salud = new SaludMondayService()
await expect(salud.sondear()).resolves.toBe(true)
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
await expect(salud.sondear()).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,73 @@
/**
* Salud de Monday (circuit breaker ligero).
*
* Estado en memoria: `mondayOk` arranca en true (optimista) y pasa a false tras
* FALLOS_PARA_CAER fallos GraphQL consecutivos reportados por monday.service.
* Mientras está caído, los callers deben saltarse Monday de inmediato (modo
* local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s
* y al recuperarse dispara el sync worker.
*
* MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday responda (pruebas).
*
* El ping se inyecta con `registrarPing()` (wiring en index.ts) para no
* importar monday.service aquí y evitar ciclos de imports.
*/
/** Fallos GraphQL consecutivos que tumban el flag */
export const FALLOS_PARA_CAER = 3
export type PingMonday = () => Promise<boolean>
export class SaludMondayService {
private mondayOk = true
private fallosConsecutivos = 0
private pingFn: PingMonday | null = null
/** Registra el callback de ping (en index.ts: () => mondayService.ping()) */
registrarPing(fn: PingMonday): void {
this.pingFn = fn
}
/** Llamar tras cada llamada GraphQL exitosa */
registrarExito(): void {
this.fallosConsecutivos = 0
this.mondayOk = true
}
/** Llamar tras cada fallo definitivo de Monday (AppError 502 de graphql()) */
registrarFallo(): void {
this.fallosConsecutivos += 1
if (this.fallosConsecutivos >= FALLOS_PARA_CAER) this.mondayOk = false
}
/** Flag crudo (sin el override MODO_LOCAL_FORZADO); útil para el cron de sondeo */
ok(): boolean {
return this.mondayOk
}
/** true si los callers pueden hablar con Monday ahora mismo */
estaDisponible(): boolean {
return this.mondayOk && process.env.MODO_LOCAL_FORZADO !== 'true'
}
/**
* Ping a Monday. Si responde, recupera el flag y devuelve true (el caller
* decide si dispara el sync). Sin ping registrado devuelve el flag actual.
*/
async sondear(): Promise<boolean> {
if (this.pingFn === null) return this.mondayOk
const vivo = await this.pingFn()
if (vivo) this.registrarExito()
return vivo
}
/** Reset completo (tests) */
restablecer(): void {
this.mondayOk = true
this.fallosConsecutivos = 0
this.pingFn = null
}
}
/** Singleton compartido por monday.service, controllers y crons */
export const saludMonday = new SaludMondayService()

View File

@@ -0,0 +1,427 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
cliente: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
cotizacion: {
updateMany: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import { registrarHandlersSync } from './sync-handlers.js'
import { SyncMondayService, type SyncRegistro, type SyncStore } from './sync-monday.service.js'
// ---------- Fixtures (mismo patrón que sync-monday.service.test.ts) ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: '{}',
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
return {
mapa,
async get(clave) {
return mapa.get(clave) ?? null
},
async set(clave, valor) {
mapa.set(clave, valor)
},
}
}
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const store: SyncStore & { rows: Map<string, SyncRegistro>; ultimo: SyncRegistro } = {
rows,
ultimo: registros[registros.length - 1]!,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
/** Monday mockeado: registra llamadas y devuelve ids controlados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
leadsCreados: [] as Record<string, unknown>[],
oportunidadesCreadas: [] as Record<string, unknown>[],
contactosCreados: [] as Record<string, unknown>[],
apartadosCreados: [] as Record<string, unknown>[],
motivos: [] as Record<string, unknown>[],
asignaciones: [] as { itemId: string; userId: number }[],
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'monday-lead-1'
},
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-1'
},
async actualizarOportunidadAfluencia(_id: string, _datos: Record<string, unknown>) {},
async crearContacto(datos: Record<string, unknown>) {
this.contactosCreados.push(datos)
return 'monday-cont-1'
},
async actualizarContacto(_id: string, _datos: Record<string, unknown>) {},
async crearApartado(datos: Record<string, unknown>) {
this.apartadosCreados.push(datos)
return 'monday-apt-1'
},
async crearOportunidad(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-cot-1'
},
async actualizarOportunidadCotizacion(_id: string, _datos: Record<string, unknown>) {},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: unknown) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearMotivoNoCompra(datos: Record<string, unknown>) {
this.motivos.push(datos)
return 'monday-motivo-1'
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
} as unknown as MondayService & {
cambios: { boardId: string; itemId: string; columna: string; valor: unknown }[]
leadsCreados: Record<string, unknown>[]
oportunidadesCreadas: Record<string, unknown>[]
contactosCreados: Record<string, unknown>[]
apartadosCreados: Record<string, unknown>[]
motivos: Record<string, unknown>[]
asignaciones: { itemId: string; userId: number }[]
}
}
const saludSana = { estaDisponible: () => true }
function crearServicio(monday: MondayService, store: SyncStore, state: StateStore) {
const svc = new SyncMondayService(monday, state, store, saludSana)
registrarHandlersSync(svc)
return svc
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.updateMany.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cliente.findUnique.mockResolvedValue(null)
prismaMock.cliente.updateMany.mockResolvedValue({})
prismaMock.cotizacion.updateMany.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('handler CREAR_LEAD', () => {
it('crea el lead en Monday y actualiza la fila local (mondayItemId + pendienteSync=false)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ datos: { nombre: 'Ana Prueba', telefono: '4771234567' } }),
referenciaLocal: 'loc_ana',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.leadsCreados).toEqual([{ nombre: 'Ana Prueba', telefono: '4771234567' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_ana' },
data: { mondayItemId: 'monday-lead-1', pendienteSync: false },
})
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})
describe('handler CONVERSION', () => {
const payloadConversion = {
oportunidadIdLocal: 'loc_op',
datos: { nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' },
}
it('sin mondayItemId en el lead (CREATE aún en cola): dependencia pendiente → PENDIENTE sin sumar intento', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_lead',
mondayItemId: null, // su CREAR_LEAD aún no se sincroniza
})
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 }) // no cuenta como fallo
const row = store.rows.get(store.ultimo.id)
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(0) // sin sumar intento
expect(row?.ultimoError).toContain('conversión')
expect(monday.oportunidadesCreadas).toEqual([]) // no tocó Monday
})
it('con el lead ya sincronizado: crea la oportunidad, marca el lead Calificado y actualiza ambos ids locales', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', mondayItemId: 'monday-lead-9' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([payloadConversion.datos])
// board 444 = MONDAY_BOARD_LEADS de test
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'monday-lead-9', columna: 'Estado', valor: 'Calificado' },
])
// Oportunidad local actualizada con su id Monday
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { mondayItemId: 'monday-op-1', pendienteSync: false },
})
// Mapping con ids Monday para el alta de clientes
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'monday-lead-9', oportunidadId: 'monday-op-1' },
})
})
})
describe('handler CREAR_CONTACTO', () => {
it('crea el Contacto y liga Cliente.mondayContactoId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_CONTACTO',
payloadJson: JSON.stringify({ datos: { nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP1' } }),
referenciaLocal: 'cli-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.contactosCreados).toHaveLength(1)
expect(prismaMock.cliente.updateMany).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'monday-cont-1' },
})
})
})
describe('handler COTIZACION_OPORTUNIDAD', () => {
const datosCot = {
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
tipoPropuestaLabel: 'CREDITO BANCARIO',
precioLista: 1_000_000,
descuentoPct: 0,
precioFinal: 1_000_000,
fechaCotizacion: '2026-09-05',
estadoLabel: 'Cotizador',
}
it('con oportunidadId Monday: ACTUALIZA esa oportunidad y liga Cotizacion.mondayItemId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'op-77' }),
referenciaLocal: 'cot-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([]) // no duplica
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'op-77' },
})
})
it('con oportunidadId local (loc_*) sin sincronizar: dependencia pendiente', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', mondayItemId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'loc_op' }),
referenciaLocal: 'cot-2',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.rows.get(store.ultimo.id)?.estado).toBe('PENDIENTE')
expect(store.rows.get(store.ultimo.id)?.intentos).toBe(0)
})
it('sin oportunidad: match por teléfono como el controller; sin match crea una nueva', async () => {
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, clienteTelefono: '4771234567' }),
referenciaLocal: 'cot-3',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
expect(monday.oportunidadesCreadas).toEqual([datosCot])
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-3' },
data: { mondayItemId: 'monday-op-cot-1' },
})
})
})
describe('handler CAMBIO_ETAPA', () => {
it('aplica el cambio al mondayItemId resuelto desde la referencia local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'op-55' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Cotizador' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
// board 555 = MONDAY_BOARD_OPORTUNIDADES de test
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-55', columna: 'Estado', valor: 'Cotizador' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { pendienteSync: false },
})
})
})
describe('handler APARTO_OPORTUNIDAD', () => {
it('mueve la oportunidad a Aparto con dedup previo (como moverAApartoConDedup)', async () => {
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ leadId: 'lead-9', oportunidadId: 'op-9' })
const monday = mondayMock()
const state = stateEnMemoria()
const store = storeEnMemoria([
registro({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9' }),
])
const svc = crearServicio(monday, store, state)
await svc.procesarPendientes()
// Dedup ANTES del cambio de etapa (prismaStateStore, como moverAApartoConDedup)
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:op-9' } }),
)
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Aparto' }])
})
})
describe('handler LIQUIDADO_ESCRITURAR', () => {
it('mueve el apartado a X Escriturar (board 666 de test)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(monday.cambios).toEqual([{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }])
})
})
describe('handler ASIGNAR_PERSONA', () => {
it('resuelve el mondayUserId del usuario local y asigna en el board correcto', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 777 })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([{ itemId: 'lead-55', userId: 777 }])
})
it('sin mapeo Monday del usuario: no-op exitoso (reintentar no crearía el mapeo)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([])
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})

View File

@@ -0,0 +1,289 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, prismaStateStore } from './automatizaciones.service.js'
import { esIdLocal } from './crm-local.service.js'
import { funnelService } from './funnel.service.js'
import type {
DatosApartado,
DatosContacto,
DatosLead,
DatosOportunidad,
DatosOportunidadAfluencia,
} from './monday.service.js'
import { prisma } from './prisma.js'
import {
DependenciaPendienteError,
syncMondayService,
type SyncMondayService,
} from './sync-monday.service.js'
/**
* Handlers del sync worker (modo local): una réplica Monday por cada tipo de
* SyncPendiente que encolan crm-local.service y los controllers.
*
* Convenciones:
* - `ctx.referenciaLocal` es el id local de la entidad (leadId loc_* /
* clienteId / cotizacionId); los payloads traen TODO lo necesario para la
* llamada Monday (los mismos datos que mandan hoy los controllers).
* - Cuando la réplica necesita el mondayItemId de algo que aún no se ha creado
* (su CREATE sigue en la cola), se lanza DependenciaPendienteError: el worker
* lo deja PENDIENTE sin sumar intento y lo reintenta en la próxima pasada.
* - Tras el éxito se actualizan los ids locales (LeadCaptura.mondayItemId +
* pendienteSync=false, Cliente.mondayContactoId/mondayApartadoId,
* Cotizacion.mondayItemId).
*
* Wiring: index.ts importa este módulo por su efecto lateral (registro sobre
* el singleton). Los tests llaman `registrarHandlersSync(svcPropio)`.
*/
/**
* Resuelve el id Monday de un item del embudo:
* - id Monday directo (o fila local ya sincronizada): se devuelve tal cual.
* - id local loc_*: el mondayItemId de su fila LeadCaptura; null si su CREATE
* aún no se sincroniza (→ DependenciaPendienteError en el caller).
*/
async function resolverMondayId(id: string | null | undefined): Promise<string | null> {
if (id === null || id === undefined) return null
const row = await prisma.leadCaptura.findUnique({ where: { leadId: id } })
if (row) return row.mondayItemId ?? (esIdLocal(row.leadId) ? null : row.leadId)
// Sin fila local: un id Monday viejo se usa tal cual; un loc_* huérfano no
// tiene destino (null → dependencia pendiente)
return esIdLocal(id) ? null : id
}
/** Lanza DependenciaPendienteError si el id Monday aún no se puede resolver */
function requerirMondayId(id: string | null, que: string): string {
if (id === null) {
throw new DependenciaPendienteError(`${que}: el item aún no existe en Monday (su CREATE sigue en la cola)`)
}
return id
}
/** Marca la fila local del embudo como sincronizada (updateMany: no truena si no existe) */
async function marcarCapturaSincronizada(leadId: string, mondayItemId?: string): Promise<void> {
await prisma.leadCaptura.updateMany({
where: { leadId },
data: { ...(mondayItemId !== undefined ? { mondayItemId } : {}), pendienteSync: false },
})
}
/** Registra todos los handlers sobre el servicio dado (singleton en prod) */
export function registrarHandlersSync(svc: SyncMondayService = syncMondayService): void {
// ---------- Captura (tableros Leads y Oportunidades) ----------
svc.registrarHandler('CREAR_LEAD', async (payload, ctx) => {
const id = await ctx.monday.crearLead(payload['datos'] as DatosLead)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('CREAR_AFLUENCIA', async (payload, ctx) => {
const id = await ctx.monday.crearOportunidadAfluencia(payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('ACTUALIZAR_AFLUENCIA', async (payload, ctx) => {
const id = requerirMondayId(
await resolverMondayId(ctx.referenciaLocal ?? (payload['oportunidadId'] as string | undefined)),
'actualizar afluencia',
)
await ctx.monday.actualizarOportunidadAfluencia(id, payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('CAMBIO_ETAPA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades' | 'apartado'
const label = payload['label'] as string
const fecha = payload['fecha'] as string | undefined
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), `cambio de etapa a "${label}"`)
const boardId = config.monday.boards[board]
await ctx.monday.cambiarValorColumna(boardId, id, 'Estado', label)
if (fecha !== undefined) await ctx.monday.cambiarValorColumna(boardId, id, 'Fecha', fecha)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('DESCARTE', async (payload, ctx) => {
const fuente = (payload['fuente'] as 'lead' | 'oportunidad' | undefined) ?? 'oportunidad'
const motivo = payload['motivo'] as string
const subcategoria = payload['subcategoria'] as string | undefined
const comentario = payload['comentario'] as string | undefined
const nombreItem = (payload['nombreItem'] as string | undefined) ?? ''
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'descarte')
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await ctx.monday.cambiarValorColumna(boardLeads, id, 'Estado', 'Descartado')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardLeads, id, 'Notas', comentario)
} else {
const boardOportunidades = config.monday.boards.oportunidades
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Estado', 'Perdido')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'COMENTARIOS', comentario)
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Motivo No Compra', motivo)
}
const motivoItemId = await ctx.monday.crearMotivoNoCompra({
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return motivoItemId
})
svc.registrarHandler('CONVERSION', async (payload, ctx) => {
const oportunidadIdLocal = payload['oportunidadIdLocal'] as string
const datos = payload['datos'] as DatosOportunidadAfluencia
const leadMondayId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'conversión (lead)')
const oportunidadId = await ctx.monday.crearOportunidadAfluencia(datos)
// El lead queda Calificado (convertido: sale de Mis leads)
await ctx.monday.cambiarValorColumna(config.monday.boards.leads, leadMondayId, 'Estado', 'Calificado')
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
await marcarCapturaSincronizada(oportunidadIdLocal, oportunidadId)
// Mapping con los ids de Monday para el alta de clientes (moverAApartoConDedup)
try {
await prisma.leadOportunidad.create({ data: { leadId: leadMondayId, oportunidadId } })
} catch {
// ya existe (reintento tras pasada a medias): el dedup sync:<id> lo cubre
}
return oportunidadId
})
// ---------- Alta de clientes (tableros Contactos y Apartado) ----------
svc.registrarHandler('CREAR_CONTACTO', async (payload, ctx) => {
const id = await ctx.monday.crearContacto(payload['datos'] as DatosContacto)
if (ctx.referenciaLocal !== null) {
await prisma.cliente.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayContactoId: id } })
}
return id
})
svc.registrarHandler('ACTUALIZAR_CONTACTO', async (payload, ctx) => {
const cliente = ctx.referenciaLocal !== null
? await prisma.cliente.findUnique({ where: { id: ctx.referenciaLocal } })
: null
const id = cliente?.mondayContactoId ?? null
if (id === null) {
throw new DependenciaPendienteError('actualizar contacto: el Contacto aún no existe en Monday')
}
await ctx.monday.actualizarContacto(id, payload['datos'] as Partial<DatosContacto>)
return id
})
svc.registrarHandler('CREAR_APARTADO', async (payload, ctx) => {
const id = await ctx.monday.crearApartado(payload['datos'] as DatosApartado)
// Alta de clientes: la referencia es el cliente local
if (typeof payload['clienteId'] === 'string') {
await prisma.cliente.updateMany({ where: { id: payload['clienteId'] }, data: { mondayApartadoId: id } })
}
if (ctx.referenciaLocal !== null) {
// "Aparto" local: la referencia es la fila LeadCaptura board=APARTADO
await marcarCapturaSincronizada(ctx.referenciaLocal, id)
// Clientes mínimos creados durante la caída (subida de documentos) ligados
// al id local del apartado: apuntan ahora al item real de Monday
await prisma.cliente.updateMany({
where: { mondayApartadoId: ctx.referenciaLocal },
data: { mondayApartadoId: id },
})
}
return id
})
// ---------- Cotizador ----------
svc.registrarHandler('COTIZACION_OPORTUNIDAD', async (payload, ctx) => {
const datos = payload['datos'] as DatosOportunidad
const oportunidadIdPayload = payload['oportunidadId'] as string | undefined
const clienteTelefono = payload['clienteTelefono'] as string | undefined
let oportunidadId: string | null = null
if (oportunidadIdPayload !== undefined) {
oportunidadId = requerirMondayId(await resolverMondayId(oportunidadIdPayload), 'cotización (oportunidad)')
} else if (clienteTelefono !== undefined) {
// Monday ya está vivo en la pasada: mismo match por teléfono que el controller
oportunidadId = await funnelService.oportunidadPorTelefono(clienteTelefono)
}
if (oportunidadId !== null) {
await ctx.monday.actualizarOportunidadCotizacion(oportunidadId, datos)
} else {
oportunidadId = await ctx.monday.crearOportunidad(datos)
}
if (ctx.referenciaLocal !== null) {
await prisma.cotizacion.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayItemId: oportunidadId } })
}
return oportunidadId
})
// ---------- Documentos y pipeline de apartados ----------
svc.registrarHandler('CHECKBOX_DOC', async (payload, ctx) => {
const checkbox = payload['checkbox'] as string
// El item Monday del apartado viaja en el payload (puede ser loc_* si el
// apartado nació en modo local: se resuelve por su fila LeadCaptura)
const itemId = (payload['itemId'] as string | undefined) ?? ctx.referenciaLocal
const id = requerirMondayId(await resolverMondayId(itemId), `checkbox "${checkbox}"`)
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, checkbox, true)
return id
})
svc.registrarHandler('APARTO_OPORTUNIDAD', async (payload, ctx) => {
const leadId = payload['leadId'] as string
// Misma resolución que funnel.moverAApartoConDedup: primero como
// oportunidad directa (modelo nuevo), luego como lead con mapping; los ids
// pueden ser locales loc_* (alta hecha en modo local)
let oportunidadId: string | null = null
const porOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId } })
if (porOportunidad) {
oportunidadId = porOportunidad.oportunidadId
} else {
const porLead = await prisma.leadOportunidad.findUnique({ where: { leadId } })
oportunidadId = porLead?.oportunidadId ?? null
}
if (oportunidadId === null) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId } })
if (captura?.board === 'OPORTUNIDADES') oportunidadId = captura.leadId
}
if (oportunidadId === null) throw new Error(`APARTO_OPORTUNIDAD sin mapping para ${leadId}`)
const opMonday = requerirMondayId(await resolverMondayId(oportunidadId), 'mover a Aparto')
// Dedup ANTES del cambio de etapa (el webhook/cron no debe crear el Apartado)
await prismaStateStore.set(claveApartadoCreado(opMonday), new Date().toISOString())
await ctx.monday.cambiarValorColumna(config.monday.boards.oportunidades, opMonday, 'Estado', 'Aparto')
return opMonday
})
svc.registrarHandler('LIQUIDADO_ESCRITURAR', async (payload, ctx) => {
const id = requerirMondayId(await resolverMondayId(payload['mondayItemId'] as string), 'liquidado → X Escriturar')
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, 'Estado', 'X Escriturar')
return id
})
// Réplica del status de una unidad cambiado en modo local (espejo → Monday)
svc.registrarHandler('DISPONIBILIDAD_UNIDAD', async (payload, ctx) => {
const codigo = payload['codigo'] as string
const status = payload['status'] as 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
await ctx.monday.actualizarDisponibilidadUnidad(codigo, status)
return codigo
})
svc.registrarHandler('ASIGNAR_PERSONA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades'
const itemId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'asignación de persona')
let userId = (payload['mondayUserId'] as number | undefined) ?? null
if (userId === null && typeof payload['usuarioId'] === 'string') {
const usuario = await prisma.usuario.findUnique({
where: { id: payload['usuarioId'] },
select: { mondayUserId: true },
})
userId = usuario?.mondayUserId ?? null
}
// Sin cuenta Monday vinculada no hay a quién asignar: no es un error
// (reintentar no crearía el mapeo), queda como no-op exitoso
if (userId === null) return 'sin-mapeo'
if (board === 'leads') await ctx.monday.asignarVendedorALead(itemId, userId)
else await ctx.monday.asignarAsesorAOportunidad(itemId, userId)
return itemId
})
}
// Wiring en prod: registrar sobre el singleton al importar el módulo (index.ts)
registrarHandlersSync()

View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { AppError } from '../utils/errors.js'
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import {
claveSync,
esFalloMonday,
MAX_INTENTOS_SYNC,
SyncMondayService,
type SyncRegistro,
type SyncStore,
} from './sync-monday.service.js'
// ---------- Fixtures ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }),
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
// createdAt creciente: el orden del arreglo ES el orden FIFO
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
return {
mapa,
async get(clave) {
return mapa.get(clave) ?? null
},
async set(clave, valor) {
mapa.set(clave, valor)
},
}
}
/** SyncStore en memoria: guarda los registros y el historial de marcados */
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = []
const store: SyncStore & { rows: Map<string, SyncRegistro>; marcados: typeof marcados } = {
rows,
marcados,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
marcados.push({ id, datos })
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
const saludSana = { estaDisponible: () => true }
const saludCaida = { estaDisponible: () => false }
// En fase A no hay handlers por defecto; monday solo se pasa al contexto
const mondayDummy = {} as MondayService
function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) {
return new SyncMondayService(mondayDummy, state, store, salud)
}
// ---------- Tests ----------
describe('esFalloMonday', () => {
it('solo los AppError 502 cuentan como fallo de Monday', () => {
expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true)
expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false)
expect(esFalloMonday(new Error('x'))).toBe(false)
})
})
describe('SyncMondayService.procesarPendientes', () => {
it('no-op cuando Monday no está disponible (no toca la cola)', async () => {
const store = storeEnMemoria([registro()])
const state = stateEnMemoria()
const svc = crearServicio(store, state, saludCaida)
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.marcados).toHaveLength(0)
expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE')
})
it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => {
const store = storeEnMemoria([
registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }),
registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }),
])
const state = stateEnMemoria()
const svc = crearServicio(store, state)
const orden: string[] = []
svc.registrarHandler('CREAR_LEAD', async (payload) => {
orden.push(String(payload.orden))
return 'item-1'
})
// Reescribo payloads para distinguir orden de ejecución
store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' })
store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' })
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 2, fallos: 0 })
expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero
expect(store.rows.get('a')?.estado).toBe('HECHO')
expect(store.rows.get('b')?.estado).toBe('HECHO')
// Dedup persistido con el id devuelto por el handler
expect(state.mapa.get(claveSync('a'))).toBe('item-1')
expect(state.mapa.get(claveSync('b'))).toBe('item-1')
})
it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => {
const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })])
const svc = crearServicio(store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/)
})
it('marca ERROR cuando el payloadJson no es JSON válido', async () => {
const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes()
expect(r.fallos).toBe(1)
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/)
})
it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió tras 4 intentos')
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(3)
expect(row?.ultimoError).toMatch(/Monday no respondió/)
})
it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió')
})
await svc.procesarPendientes()
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1)
})
it('un error que NO es de Monday marca ERROR sin reintento', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new Error('payload incompleto: falta nombre')
})
await svc.procesarPendientes()
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/)
})
it('idempotencia: con sync:<id> ya en el state no reejecuta el handler', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const state = stateEnMemoria()
state.mapa.set(claveSync('x'), 'item-ya-creado')
const svc = crearServicio(store, state)
let llamadas = 0
svc.registrarHandler('CREAR_LEAD', async () => {
llamadas++
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(llamadas).toBe(0) // no duplica el item en Monday
expect(store.rows.get('x')?.estado).toBe('HECHO')
})
it('respeta el límite por pasada', async () => {
const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes({ limite: 2 })
expect(r.procesados).toBe(2)
expect(store.rows.get('c')?.estado).toBe('PENDIENTE')
})
it('una pasada en curso bloquea la siguiente (mutex)', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
let liberar!: () => void
const bloqueo = new Promise<void>((res) => {
liberar = res
})
svc.registrarHandler('CREAR_LEAD', async () => {
await bloqueo
})
const pasada1 = svc.procesarPendientes()
// Segunda pasada mientras la primera sigue: no-op inmediato
await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 })
liberar()
await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 })
})
})

View File

@@ -0,0 +1,216 @@
import { AppError } from '../utils/errors.js'
import { prismaStateStore, type StateStore } from './automatizaciones.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
/**
* Sync worker del modo local: drena la cola SyncPendiente hacia Monday.
*
* - Solo corre cuando Monday está disponible (saludMonday); si está caído es no-op.
* - FIFO por createdAt (la captura encoló en orden: lead → conversión →
* afluencia → apartado → contacto) y SECUENCIAL: SQLite serializa las
* escrituras (P1008) si se procesa en paralelo.
* - Idempotencia con AutomatizacionState, clave `sync:<id>` (mismo patrón que
* `apartado-creado:<id>` de automatizaciones): si el proceso muere a media
* pasada, al reintentar no duplica el item en Monday.
* - Fallo Monday (AppError 502): vuelve a PENDIENTE con intentos+1; al superar
* MAX_INTENTOS queda ERROR (visible en /api/sistema/estado). Cualquier otro
* error (tipo sin handler, payload inválido) marca ERROR de inmediato.
*
* Los handlers por tipo los registra la fase B (con los payloads reales de
* cada flujo) vía `registrarHandler`; el mapa queda extensible y defensivo.
*/
export const TIPOS_SYNC = [
'CREAR_LEAD',
'CREAR_AFLUENCIA',
'ACTUALIZAR_AFLUENCIA',
'CAMBIO_ETAPA',
'DESCARTE',
'CONVERSION',
'CREAR_CONTACTO',
'ACTUALIZAR_CONTACTO',
'CREAR_APARTADO',
'COTIZACION_OPORTUNIDAD',
'CHECKBOX_DOC',
'APARTO_OPORTUNIDAD',
'LIQUIDADO_ESCRITURAR',
'ASIGNAR_PERSONA',
'DISPONIBILIDAD_UNIDAD',
] as const
export type TipoSync = (typeof TIPOS_SYNC)[number]
/** Intentos máximos antes de declarar ERROR definitivo */
export const MAX_INTENTOS_SYNC = 10
/** Registro de la cola tal como lo consume el worker */
export interface SyncRegistro {
id: string
tipo: string
payloadJson: string
referenciaLocal: string | null
estado: string
intentos: number
createdAt: Date
}
/** Acceso mínimo a la tabla SyncPendiente (Prisma en prod, memoria en tests) */
export interface SyncStore {
/** Hasta `limite` registros PENDIENTE en orden FIFO (createdAt asc) */
pendientes(limite: number): Promise<SyncRegistro[]>
marcar(
id: string,
datos: { estado: string; intentos?: number; ultimoError?: string | null },
): Promise<void>
}
export interface ContextoSync {
monday: MondayService
referenciaLocal: string | null
}
/**
* Ejecuta la réplica en Monday. Devuelve el id creado (o cualquier cadena) para
* guardarlo en el dedup `sync:<id>`; void se persiste como 'ok'.
*/
export type HandlerSync = (
payload: Record<string, unknown>,
ctx: ContextoSync,
) => Promise<string | void>
export interface ResultadoSync {
procesados: number
fallos: number
}
/** true si el error es un fallo de Monday retryable (AppError 502 de graphql) */
export function esFalloMonday(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/**
* El handler no puede ejecutarse TODAVÍA porque una operación previa de la
* cola aún no se sincroniza (p.ej. CAMBIO_ETAPA sobre un item cuyo CREATE no
* ha corrido: no hay mondayItemId). El worker lo deja PENDIENTE SIN sumar
* intento: se reintenta en la siguiente pasada, cuando su dependencia ya se
* haya aplicado (la cola es FIFO).
*/
export class DependenciaPendienteError extends Error {
constructor(message: string) {
super(message)
this.name = 'DependenciaPendienteError'
}
}
/** Clave de dedup/idempotencia del registro en AutomatizacionState */
export function claveSync(id: string): string {
return `sync:${id}`
}
export class SyncMondayService {
private readonly handlers = new Map<string, HandlerSync>()
/** Mutex en memoria: evita solapar el cron de 2 min con el disparo al recuperar salud */
private enEjecucion = false
constructor(
private readonly monday: MondayService,
private readonly state: StateStore,
private readonly db: SyncStore,
private readonly salud: Pick<SaludMondayService, 'estaDisponible'> = saludMonday,
) {}
/** Registra el handler de un tipo de réplica (lo usa la fase B por flujo) */
registrarHandler(tipo: TipoSync, handler: HandlerSync): void {
this.handlers.set(tipo, handler)
}
/**
* Una pasada por la cola (máx `limite` registros). No-op si Monday está
* caído / MODO_LOCAL_FORZADO, o si ya hay una pasada en curso.
*/
async procesarPendientes({ limite = 25 }: { limite?: number } = {}): Promise<ResultadoSync> {
if (!this.salud.estaDisponible()) return { procesados: 0, fallos: 0 }
if (this.enEjecucion) return { procesados: 0, fallos: 0 }
this.enEjecucion = true
try {
const pendientes = await this.db.pendientes(limite)
let procesados = 0
let fallos = 0
for (const registro of pendientes) {
await this.db.marcar(registro.id, { estado: 'PROCESANDO' })
try {
await this.ejecutar(registro)
await this.db.marcar(registro.id, { estado: 'HECHO', ultimoError: null })
procesados++
} catch (err) {
const mensaje = err instanceof Error ? err.message : String(err)
if (err instanceof DependenciaPendienteError) {
// Falta una operación previa de la cola: queda PENDIENTE sin sumar
// intento (no es un fallo; se reintenta en la próxima pasada)
await this.db.marcar(registro.id, { estado: 'PENDIENTE', ultimoError: mensaje })
} else {
fallos++
const intentos = registro.intentos + 1
if (esFalloMonday(err)) {
// Monday volvió a caer a media pasada: reintenta en la siguiente
await this.db.marcar(registro.id, {
estado: intentos > MAX_INTENTOS_SYNC ? 'ERROR' : 'PENDIENTE',
intentos,
ultimoError: mensaje,
})
} else {
// Error de datos/código: reintentar no lo arregla
await this.db.marcar(registro.id, { estado: 'ERROR', intentos, ultimoError: mensaje })
}
}
}
}
if (procesados + fallos > 0) {
// eslint-disable-next-line no-console
console.log(`[sync] procesados ${procesados}, fallos ${fallos}`)
}
return { procesados, fallos }
} finally {
this.enEjecucion = false
}
}
/** Ejecuta un registro con dedup `sync:<id>`; lanza para que el caller clasifique */
private async ejecutar(registro: SyncRegistro): Promise<void> {
const handler = this.handlers.get(registro.tipo)
if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`)
let payload: Record<string, unknown>
try {
payload = JSON.parse(registro.payloadJson) as Record<string, unknown>
} catch {
throw new Error(`payloadJson inválido (no es JSON): registro ${registro.id}`)
}
const clave = claveSync(registro.id)
const previo = await this.state.get(clave)
if (previo !== null) return // ya se aplicó en una pasada previa (dedup)
const resultado = await handler(payload, {
monday: this.monday,
referenciaLocal: registro.referenciaLocal,
})
await this.state.set(clave, typeof resultado === 'string' && resultado !== '' ? resultado : 'ok')
}
}
/** Store respaldado por la tabla SyncPendiente de Prisma */
export const prismaSyncStore: SyncStore = {
pendientes(limite) {
return prisma.syncPendiente.findMany({
where: { estado: 'PENDIENTE' },
orderBy: { createdAt: 'asc' },
take: limite,
})
},
async marcar(id, datos) {
await prisma.syncPendiente.update({ where: { id }, data: datos })
},
}
/** Singleton usado por los crons de index.ts (los tests instancian el suyo) */
export const syncMondayService = new SyncMondayService(mondayService, prismaStateStore, prismaSyncStore)

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidades: vi.fn(),
getUnidad: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import {
getUnidadesLocal,
getUnidadLocal,
listarUnidadesConFallback,
obtenerUnidadConFallback,
unidadLocalADto,
type FilaUnidad,
} from './unidades-local.service.js'
import { saludMonday } from './salud-monday.service.js'
function fila(parcial: Partial<FilaUnidad> & { codigo: string }): FilaUnidad {
return {
desarrollo: 'SENDA',
modelo: '1R',
supConstruccion: 69.5,
supExcedente: 0,
supEstacionamiento: 12.5,
precioLista: 4_415_000,
precioAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: 'Entrega inmediata',
...parcial,
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.findUnique.mockResolvedValue(null)
})
describe('unidadLocalADto', () => {
it('mapea la fila local al DTO Unidad (misma forma que Monday)', () => {
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
expect(dto).toEqual({
codigo: 'SENDA-101',
modelo: '1R',
fechaEntrega: null,
supEstacionamiento: 12.5,
supConstruccion: 69.5,
supExcedente: 0,
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
mesesParaFirma: null,
etapa: 'Entrega inmediata',
})
})
it('etapa vacía → null (como el mapeo Monday)', () => {
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
})
})
describe('getUnidadesLocal / getUnidadLocal', () => {
it('devuelve las filas mapeadas; soloDisponibles y desarrollo filtran', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'SENDA', status: 'APARTADO' }),
fila({ codigo: 'U-3', desarrollo: 'senda' }),
])
const todas = await getUnidadesLocal()
expect(todas.map((u) => u.codigo)).toEqual(['U-1', 'U-2', 'U-3'])
const disponibles = await getUnidadesLocal({ soloDisponibles: true })
expect(disponibles.map((u) => u.codigo)).toEqual(['U-1', 'U-3'])
const senda = await getUnidadesLocal({ desarrollo: 'SENDA' })
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
})
it('getUnidadLocal: null si no existe', async () => {
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
)
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9')
expect(await getUnidadLocal('U-X')).toBeNull()
})
})
describe('fallbacks con circuit breaker', () => {
it('Monday caído (salud) → lee local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
const lista = await listarUnidadesConFallback()
expect(lista.map((u) => u.codigo)).toEqual(['U-1'])
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const unidad = await obtenerUnidadConFallback('U-1')
expect(unidad?.codigo).toBe('U-1')
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
})
it('Monday vivo → camino normal (sin tocar local)', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1' }])
mondayMock.getUnidad.mockResolvedValue({ codigo: 'M-1' })
expect(await listarUnidadesConFallback()).toEqual([{ codigo: 'M-1' }])
expect(await obtenerUnidadConFallback('M-1')).toEqual({ codigo: 'M-1' })
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday lanza AppError 502 → fallback local', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
expect((await listarUnidadesConFallback()).map((u) => u.codigo)).toEqual(['U-1'])
expect((await obtenerUnidadConFallback('U-1'))?.codigo).toBe('U-1')
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidadesConFallback()).rejects.toThrow('bug')
})
})

View File

@@ -0,0 +1,100 @@
import { mondayService, type Unidad } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
import { AppError } from '../utils/errors.js'
/**
* Fallback de LECTURA del inventario (modo local): la tabla `Unidad` es el
* espejo local del tablero Unidades de Monday (la siembra
* scripts/seed-unidades-locales.js y la mantiene fresco el write-through de
* monday.service.getUnidades). Cuando Monday está caído (saludMonday) o lanza
* AppError 502, el cotizador lee de aquí con la MISMA forma de DTO.
*
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
*/
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
export interface FilaUnidad {
codigo: string
desarrollo: string
modelo: string
supConstruccion: number
supExcedente: number
supEstacionamiento: number
precioLista: number
precioAcabados: number
status: string
etapa: string
}
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
export function unidadLocalADto(u: FilaUnidad): Unidad {
return {
codigo: u.codigo,
modelo: u.modelo,
fechaEntrega: null, // no está en el espejo local: plazo desconocido
supEstacionamiento: u.supEstacionamiento,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
precioSinAcabados: u.precioLista,
precioConAcabados: u.precioAcabados,
status: u.status,
desarrollo: u.desarrollo,
mesesParaFirma: null,
etapa: u.etapa === '' ? null : u.etapa,
}
}
/** true si el error es un fallo de Monday (AppError 502) que amerita fallback local */
function esFalloMonday502(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/** Unidades del espejo local; filtros equivalentes a los del controller */
export async function getUnidadesLocal(
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
): Promise<Unidad[]> {
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
let lista = rows.map(unidadLocalADto)
if (filtro.soloDisponibles === true) {
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
}
if (filtro.desarrollo !== undefined && filtro.desarrollo !== '') {
const desarrollo = filtro.desarrollo.toUpperCase()
lista = lista.filter((u) => (u.desarrollo || 'DIAGONAL').toUpperCase() === desarrollo)
}
return lista
}
/** Una unidad local por código; null si no existe en el espejo */
export async function getUnidadLocal(codigo: string): Promise<Unidad | null> {
const row = await prisma.unidad.findUnique({ where: { codigo } })
return row === null ? null : unidadLocalADto(row)
}
/**
* Lectura con fallback: Monday si está disponible; espejo local si el circuit
* breaker lo da por caído o si la llamada lanza AppError 502. Cualquier otro
* error se propaga intacto (no se traga nada del camino feliz).
*/
export async function listarUnidadesConFallback(): Promise<Unidad[]> {
if (!saludMonday.estaDisponible()) return getUnidadesLocal()
try {
return await mondayService.getUnidades()
} catch (err) {
if (esFalloMonday502(err)) return getUnidadesLocal()
throw err
}
}
/** Igual que listarUnidadesConFallback pero para una unidad por código */
export async function obtenerUnidadConFallback(codigo: string): Promise<Unidad | null> {
if (!saludMonday.estaDisponible()) return getUnidadLocal(codigo)
try {
return await mondayService.getUnidad(codigo)
} catch (err) {
if (esFalloMonday502(err)) return getUnidadLocal(codigo)
throw err
}
}