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

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

View File

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