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,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')
})
})