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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user