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