Dashboard de seguimiento de proyectos: integración de Primavera P6 con Fastify y React
Cómo extraer datos reales de Primavera P6 y exponerlos en un dashboard React+Vite con un backend Fastify+TS. Arquitectura, endpoints y lecciones de campo.
Por qué construir tu propio dashboard en lugar de usar P6 Web
Primavera P6 Professional guarda toda su información en Oracle (o SQLite en instalaciones pequeñas). El módulo P6 Web Access existe, pero en proyectos mineros como los que he acompañado —SMCV con más de 1 200 actividades en un shutdown de 21 días— el reporte estándar tarda entre 4 y 7 minutos en renderizar y no permite cruzar datos de campo con los del schedule. La solución que describo aquí resuelve eso: un backend Fastify que consulta directamente la base de datos de P6 y un frontend React que muestra el estado en tiempo casi real.
No es un reemplazo de P6. Es una capa de lectura sobre él.
Arquitectura general
┌─────────────────────────────────────────────────────┐
│ Primavera P6 Professional (Oracle 19c / SQLite) │
└────────────────────┬────────────────────────────────┘
│ JDBC / node-oracledb / better-sqlite3
┌────────────────────▼────────────────────────────────┐
│ Backend Fastify 4 + TypeScript │
│ • /api/projects lista de proyectos │
│ • /api/wbs/:projId árbol WBS │
│ • /api/activities/:wbsId actividades + SPI/CPI │
│ • /api/resources/:actId asignación de recursos │
│ • WebSocket /ws/live push cada 60 s │
└────────────────────┬────────────────────────────────┘
│ REST + WS
┌────────────────────▼────────────────────────────────┐
│ Frontend React 18 + TypeScript + Vite 5 │
│ • Gantt simplificado (react-gantt-chart) │
│ • Curva S (Recharts) │
│ • Tabla de actividades críticas │
│ • KPI cards: SPI, CPI, Float total │
└─────────────────────────────────────────────────────┘
El backend nunca escribe en P6. Solo SELECT. Eso elimina el riesgo de corromper el schedule.
Backend: Fastify + TypeScript
Conexión a la base de datos de P6
P6 Professional sobre Oracle usa el esquema PMSYS (nombre por defecto). Las tablas clave son TASK, PROJECT, TASKRSRC, RSRC y PROJWBS.
// src/db/oracle.ts
import oracledb from 'oracledb';
const pool = await oracledb.createPool({
user: process.env.P6_DB_USER, // 'pmsys'
password: process.env.P6_DB_PASS,
connectString: process.env.P6_DSN, // 'host:1521/ORCL'
poolMin: 2,
poolMax: 10,
poolIncrement: 1,
});
export async function query<T>(sql: string, binds: unknown[] = []): Promise<T[]> {
const conn = await pool.getConnection();
try {
const result = await conn.execute<T>(sql, binds, { outFormat: oracledb.OUT_FORMAT_OBJECT });
return result.rows ?? [];
} finally {
await conn.close();
}
}
Endpoint de actividades con SPI
P6 almacena el PHYS_COMPLETE_PCT y las fechas de línea base en TASK. El SPI lo calculo en el backend para no cargar el frontend con lógica de negocio.
// src/routes/activities.ts
import { FastifyInstance } from 'fastify';
import { query } from '../db/oracle';
interface Activity {
TASK_ID: number;
TASK_NAME: string;
TARGET_START_DATE: Date;
TARGET_END_DATE: Date;
ACT_START_DATE: Date | null;
PHYS_COMPLETE_PCT: number;
TOTAL_FLOAT_HR_CNT: number;
SPI: number;
}
export async function activitiesRoute(app: FastifyInstance) {
app.get<{ Params: { wbsId: string } }>('/api/activities/:wbsId', async (req, reply) => {
const rows = await query<Activity>(
`SELECT
t.TASK_ID,
t.TASK_NAME,
t.TARGET_START_DATE,
t.TARGET_END_DATE,
t.ACT_START_DATE,
t.PHYS_COMPLETE_PCT,
t.TOTAL_FLOAT_HR_CNT,
ROUND(
t.PHYS_COMPLETE_PCT /
NULLIF(
(SYSDATE - t.TARGET_START_DATE) /
NULLIF(t.TARGET_END_DATE - t.TARGET_START_DATE, 0) * 100,
0), 3
) AS SPI
FROM PMSYS.TASK t
WHERE t.WBS_ID = :wbsId
AND t.STATUS_CODE != 'TK_NotStart'
ORDER BY t.TARGET_START_DATE`,
[req.params.wbsId]
);
return reply.send(rows);
});
}
Nota de campo: en proyectos con Oracle 12c (instalaciones antiguas en sitio minero) el
SYSDATEdevuelve la hora del servidor de base de datos, no la del servidor de aplicación. Sincroniza NTP antes de desplegar.
Validación de esquema con Zod
Uso Zod en el boundary de la API para no confiar ciegamente en lo que devuelve Oracle:
import { z } from 'zod';
export const ActivitySchema = z.object({
TASK_ID: z.number(),
TASK_NAME: z.string(),
PHYS_COMPLETE_PCT: z.number().min(0).max(100),
TOTAL_FLOAT_HR_CNT: z.number(),
SPI: z.number().nullable(),
});
export type ActivityDTO = z.infer<typeof ActivitySchema>;
Frontend: React + TypeScript + Vite
Estructura de carpetas
src/
api/ # hooks de fetching (TanStack Query)
components/
KpiCard/
GanttPanel/
SCurve/
ActivityTable/
pages/
ProjectDashboard.tsx
types/ # ActivityDTO, WbsNode, etc.
Hook de datos con TanStack Query
// src/api/useActivities.ts
import { useQuery } from '@tanstack/react-query';
import type { ActivityDTO } from '../types';
export function useActivities(wbsId: string) {
return useQuery<ActivityDTO[]>({
queryKey: ['activities', wbsId],
queryFn: async () => {
const res = await fetch(`/api/activities/${wbsId}`);
if (!res.ok) throw new Error('Error al obtener actividades');
return res.json();
},
refetchInterval: 60_000, // refresco cada 60 s
staleTime: 30_000,
});
}
KPI cards
| KPI | Fuente en P6 | Umbral alerta |
|---|---|---|
| SPI | PHYS_COMPLETE_PCT vs avance planificado |
< 0.95 |
| CPI | ACT_COST vs TARGET_COST |
< 0.90 |
| Float total mínimo | TOTAL_FLOAT_HR_CNT |
< 8 h |
| Actividades críticas abiertas | TOTAL_FLOAT_HR_CNT = 0 |
> 5 |
La lógica de color (verde / ámbar / rojo) vive en una función pura getKpiStatus(value, thresholds) que importan todos los componentes. No hay lógica de negocio dispersa en JSX.
Curva S con Recharts
La curva S requiere datos acumulados por período. El backend expone /api/scurve/:projId?period=week que agrupa TARGET_COST y ACT_COST por semana ISO. El frontend solo renderiza:
<ComposedChart data={scurveData}>
<Area dataKey="planned" stroke="#6366f1" fill="#e0e7ff" />
<Line dataKey="actual" stroke="#dc2626" dot={false} />
<XAxis dataKey="week" />
<YAxis tickFormatter={(v) => `$${(v/1e6).toFixed(1)}M`} />
<Tooltip />
</ComposedChart>
Despliegue en sitio
En proyectos mineros el acceso a internet es restringido. El stack corre en una VM local (Windows Server 2019) dentro de la red del proyecto:
- Fastify expuesto en el puerto 3001 detrás de Nginx.
- React compilado con
vite buildy servido como estático por el mismo Nginx. - La base de datos Oracle de P6 queda en la misma VLAN; no hay tráfico fuera del site.
En ENGIE usé esta arquitectura durante un outage de 72 h en una central termoeléctrica: el dashboard permitió al equipo de turno ver el avance sin abrir P6 Professional, que requería licencia flotante y a veces no estaba disponible.
Lo que aprendí después de tres despliegues
- No calcules el SPI en el frontend. Los datos de P6 tienen nulos y fechas inconsistentes. Centraliza esa lógica en el backend con pruebas unitarias.
- Agrega un endpoint de salud (
/health) que verifique la conexión al pool de Oracle. Nginx lo usa como upstream check. - El refresco de 60 segundos es suficiente para un shutdown. No uses WebSocket si no tienes un caso real de tiempo real; agrega complejidad operacional sin beneficio.
- Documenta las tablas de P6 que tocas. Oracle Primavera no garantiza estabilidad de esquema entre versiones menores. En el paso de P6 20.12 a 21.12 la columna
PHYS_COMPLETE_PCTcambió de escala en algunas instalaciones.
Este dashboard no reemplaza al PM. Reemplaza las horas que el PM pierde esperando que P6 Web cargue.