Nexus
Primavera P6 en la práctica

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 SYSDATE devuelve 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 build y 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

  1. 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.
  2. Agrega un endpoint de salud (/health) que verifique la conexión al pool de Oracle. Nginx lo usa como upstream check.
  3. 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.
  4. 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_PCT cambió de escala en algunas instalaciones.

Este dashboard no reemplaza al PM. Reemplaza las horas que el PM pierde esperando que P6 Web cargue.