feat(modo-local): fallback completo sin Monday + cola de sincronización

- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

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