1147 lines
32 KiB
Markdown
1147 lines
32 KiB
Markdown
# ET-PROG-004: Implementación de Dashboard y Reportes de Avances
|
|
|
|
**Épica:** MAI-005 - Control de Obra y Avances
|
|
**Módulo:** Dashboard y Reportes
|
|
**Responsable Técnico:** Backend + Frontend + BI
|
|
**Fecha:** 2025-11-17
|
|
**Versión:** 1.0
|
|
|
|
---
|
|
|
|
## 1. Objetivo Técnico
|
|
|
|
Implementar el dashboard ejecutivo y sistema de reportes con:
|
|
- Dashboard en tiempo real con KPIs principales
|
|
- Mapa de calor de avances por unidad
|
|
- Análisis de productividad por cuadrilla
|
|
- Generación de reportes oficiales (INFONAVIT, cliente)
|
|
- Exportación a PDF y Excel
|
|
- Firma digital de reportes
|
|
- Notificaciones de alertas críticas
|
|
|
|
---
|
|
|
|
## 2. Stack Tecnológico
|
|
|
|
### Backend
|
|
```typescript
|
|
- NestJS 10+ con TypeScript
|
|
- TypeORM para PostgreSQL
|
|
- PostgreSQL 15+ (schema: analytics)
|
|
- node-cron para cálculos programados
|
|
- EventEmitter2 para eventos en tiempo real
|
|
- ExcelJS para generación de Excel
|
|
- PDFKit para generación de PDFs
|
|
- WebSocket para actualizaciones en vivo
|
|
```
|
|
|
|
### Frontend
|
|
```typescript
|
|
- React 18 con TypeScript
|
|
- Chart.js / Recharts para gráficas
|
|
- react-grid-layout para widgets drag&drop
|
|
- Zustand para state management
|
|
- Socket.io-client para WebSocket
|
|
- jsPDF para PDFs en cliente
|
|
- react-to-print para impresión
|
|
```
|
|
|
|
### BI y Analytics
|
|
```typescript
|
|
- PostgreSQL Materialized Views
|
|
- Window Functions para análisis
|
|
- Stored Procedures para agregaciones
|
|
- CRON jobs para precalcular métricas
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Modelo de Datos SQL
|
|
|
|
```sql
|
|
-- =====================================================
|
|
-- SCHEMA: analytics
|
|
-- Descripción: Analytics, KPIs y reportes
|
|
-- =====================================================
|
|
|
|
CREATE SCHEMA IF NOT EXISTS analytics;
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.kpi_metrics
|
|
-- Descripción: Métricas KPI calculadas diariamente
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.kpi_metrics (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
|
|
-- Fecha de la métrica
|
|
metric_date DATE NOT NULL,
|
|
|
|
-- Avance
|
|
physical_progress DECIMAL(5,2) NOT NULL, -- % avance físico
|
|
financial_progress DECIMAL(5,2) NOT NULL, -- % avance financiero
|
|
time_elapsed DECIMAL(5,2) NOT NULL, -- % tiempo transcurrido
|
|
|
|
-- Earned Value Management
|
|
planned_value_pv DECIMAL(15,2),
|
|
earned_value_ev DECIMAL(15,2),
|
|
actual_cost_ac DECIMAL(15,2),
|
|
spi DECIMAL(5,3), -- Schedule Performance Index
|
|
cpi DECIMAL(5,3), -- Cost Performance Index
|
|
|
|
-- Desviaciones
|
|
schedule_variance_sv DECIMAL(15,2), -- EV - PV
|
|
cost_variance_cv DECIMAL(15,2), -- EV - AC
|
|
schedule_variance_pct DECIMAL(5,2),
|
|
cost_variance_pct DECIMAL(5,2),
|
|
|
|
-- Proyecciones
|
|
estimate_at_completion_eac DECIMAL(15,2),
|
|
estimate_to_complete_etc DECIMAL(15,2),
|
|
variance_at_completion_vac DECIMAL(15,2),
|
|
|
|
-- Recursos
|
|
active_crews INTEGER,
|
|
total_workers INTEGER,
|
|
productive_hours DECIMAL(10,2),
|
|
nonproductive_hours DECIMAL(10,2),
|
|
efficiency_pct DECIMAL(5,2),
|
|
|
|
-- Calidad
|
|
quality_inspections INTEGER,
|
|
total_nc INTEGER, -- no conformidades
|
|
critical_nc INTEGER,
|
|
open_nc INTEGER,
|
|
closed_nc INTEGER,
|
|
|
|
-- Alertas
|
|
critical_alerts INTEGER,
|
|
warning_alerts INTEGER,
|
|
info_alerts INTEGER,
|
|
|
|
-- Metadata
|
|
calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(project_id, metric_date)
|
|
);
|
|
|
|
CREATE INDEX idx_kpi_project ON analytics.kpi_metrics(project_id);
|
|
CREATE INDEX idx_kpi_date ON analytics.kpi_metrics(metric_date);
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.productivity_metrics
|
|
-- Descripción: Métricas de productividad por cuadrilla
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.productivity_metrics (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
crew_id UUID NOT NULL REFERENCES projects.crews(id),
|
|
activity_id UUID REFERENCES schedules.schedule_activities(id),
|
|
|
|
-- Período
|
|
period_start DATE NOT NULL,
|
|
period_end DATE NOT NULL,
|
|
|
|
-- Rendimiento
|
|
planned_rate DECIMAL(10,4), -- unidades/día planificadas
|
|
actual_rate DECIMAL(10,4), -- unidades/día reales
|
|
efficiency DECIMAL(5,2), -- (actual/planned) * 100
|
|
|
|
-- Producción
|
|
unit VARCHAR(20),
|
|
quantity_produced DECIMAL(12,4),
|
|
labor_hours DECIMAL(10,2),
|
|
workers_count INTEGER,
|
|
|
|
-- Costos
|
|
labor_cost DECIMAL(15,2),
|
|
cost_per_unit DECIMAL(15,4),
|
|
|
|
-- Metadata
|
|
calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(project_id, crew_id, activity_id, period_start, period_end)
|
|
);
|
|
|
|
CREATE INDEX idx_productivity_project ON analytics.productivity_metrics(project_id);
|
|
CREATE INDEX idx_productivity_crew ON analytics.productivity_metrics(crew_id);
|
|
CREATE INDEX idx_productivity_activity ON analytics.productivity_metrics(activity_id);
|
|
CREATE INDEX idx_productivity_period ON analytics.productivity_metrics(period_start, period_end);
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.dashboard_widgets
|
|
-- Descripción: Configuración de widgets del dashboard
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.dashboard_widgets (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Usuario
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Tipo de widget
|
|
widget_type VARCHAR(50) NOT NULL,
|
|
-- progress_overview, s_curve, heatmap, productivity_chart,
|
|
-- alerts_summary, quality_metrics, crew_performance
|
|
|
|
-- Posición en el dashboard
|
|
position INTEGER NOT NULL,
|
|
grid_x INTEGER DEFAULT 0,
|
|
grid_y INTEGER DEFAULT 0,
|
|
grid_w INTEGER DEFAULT 4, -- ancho en columnas (12 columnas total)
|
|
grid_h INTEGER DEFAULT 3, -- alto en filas
|
|
|
|
-- Tamaño
|
|
size VARCHAR(20) DEFAULT 'medium',
|
|
-- small, medium, large, full
|
|
|
|
-- Configuración del widget
|
|
config JSONB,
|
|
/* {
|
|
projectId: "uuid",
|
|
chartType: "line",
|
|
timeRange: "30d",
|
|
metrics: ["spi", "cpi"],
|
|
filters: {...}
|
|
} */
|
|
|
|
-- Visibilidad
|
|
is_visible BOOLEAN DEFAULT true,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(user_id, widget_type, position)
|
|
);
|
|
|
|
CREATE INDEX idx_widgets_user ON analytics.dashboard_widgets(user_id);
|
|
CREATE INDEX idx_widgets_visible ON analytics.dashboard_widgets(is_visible) WHERE is_visible = true;
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.reports_generated
|
|
-- Descripción: Reportes generados
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.reports_generated (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Tipo de reporte
|
|
report_type VARCHAR(50) NOT NULL,
|
|
-- infonavit_progress, executive_summary, quality_report,
|
|
-- productivity_analysis, financial_status, custom
|
|
|
|
-- Proyecto
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
|
|
-- Período
|
|
period_start DATE NOT NULL,
|
|
period_end DATE NOT NULL,
|
|
|
|
-- Template utilizado
|
|
template VARCHAR(100),
|
|
|
|
-- Formato
|
|
format VARCHAR(20) NOT NULL, -- pdf, excel, pptx, html
|
|
|
|
-- Archivo
|
|
file_path VARCHAR(512) NOT NULL,
|
|
file_size INTEGER,
|
|
|
|
-- Secciones incluidas
|
|
included_sections VARCHAR[],
|
|
|
|
-- Parámetros de generación
|
|
generation_params JSONB,
|
|
|
|
-- Firma digital
|
|
digitally_signed BOOLEAN DEFAULT false,
|
|
signed_by UUID REFERENCES auth.users(id),
|
|
signed_at TIMESTAMP,
|
|
signature_data TEXT,
|
|
|
|
-- Envío
|
|
sent_to VARCHAR[],
|
|
sent_at TIMESTAMP,
|
|
delivery_status VARCHAR(20), -- pending, sent, delivered, failed
|
|
|
|
-- Metadata
|
|
generated_by UUID NOT NULL REFERENCES auth.users(id),
|
|
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT valid_format CHECK (format IN ('pdf', 'excel', 'pptx', 'html')),
|
|
CONSTRAINT valid_delivery_status CHECK (delivery_status IN ('pending', 'sent', 'delivered', 'failed'))
|
|
);
|
|
|
|
CREATE INDEX idx_reports_project ON analytics.reports_generated(project_id);
|
|
CREATE INDEX idx_reports_type ON analytics.reports_generated(report_type);
|
|
CREATE INDEX idx_reports_date ON analytics.reports_generated(generated_at);
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.unit_heatmap_data
|
|
-- Descripción: Datos precalculados para mapa de calor
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.unit_heatmap_data (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
unit_id UUID NOT NULL REFERENCES projects.units(id) ON DELETE CASCADE,
|
|
|
|
-- Fecha del snapshot
|
|
snapshot_date DATE NOT NULL,
|
|
|
|
-- Avance global de la unidad
|
|
overall_progress_pct DECIMAL(5,2) NOT NULL,
|
|
|
|
-- Avance por etapa
|
|
stages_progress JSONB,
|
|
/* {
|
|
"cimentacion": 100,
|
|
"estructura": 85,
|
|
"instalaciones": 60,
|
|
"acabados": 20
|
|
} */
|
|
|
|
-- Estado
|
|
status VARCHAR(20) NOT NULL,
|
|
-- not_started, in_progress, completed, delayed
|
|
|
|
-- Días de retraso/adelanto
|
|
days_behind_schedule INTEGER,
|
|
days_ahead_schedule INTEGER,
|
|
|
|
-- Alertas
|
|
has_critical_alerts BOOLEAN DEFAULT false,
|
|
alert_count INTEGER DEFAULT 0,
|
|
|
|
-- Color del heatmap (precalculado)
|
|
heatmap_color VARCHAR(7), -- #RRGGBB
|
|
|
|
-- Metadata
|
|
calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT valid_status CHECK (status IN ('not_started', 'in_progress', 'completed', 'delayed')),
|
|
UNIQUE(project_id, unit_id, snapshot_date)
|
|
);
|
|
|
|
CREATE INDEX idx_heatmap_project ON analytics.unit_heatmap_data(project_id);
|
|
CREATE INDEX idx_heatmap_unit ON analytics.unit_heatmap_data(unit_id);
|
|
CREATE INDEX idx_heatmap_date ON analytics.unit_heatmap_data(snapshot_date);
|
|
|
|
-- =====================================================
|
|
-- TABLE: analytics.alerts
|
|
-- Descripción: Alertas del sistema
|
|
-- =====================================================
|
|
|
|
CREATE TABLE analytics.alerts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
|
|
-- Tipo de alerta
|
|
alert_type VARCHAR(50) NOT NULL,
|
|
-- schedule_delay, budget_overrun, quality_issue, resource_shortage,
|
|
-- safety_incident, material_shortage, weather_delay
|
|
|
|
-- Severidad
|
|
severity VARCHAR(20) NOT NULL, -- critical, warning, info
|
|
|
|
-- Título y mensaje
|
|
title VARCHAR(255) NOT NULL,
|
|
message TEXT NOT NULL,
|
|
|
|
-- Entidad relacionada
|
|
related_entity_type VARCHAR(50), -- activity, unit, crew, material
|
|
related_entity_id UUID,
|
|
|
|
-- Valor de la alerta
|
|
threshold_value DECIMAL(15,4),
|
|
current_value DECIMAL(15,4),
|
|
variance DECIMAL(15,4),
|
|
|
|
-- Estado
|
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
|
-- active, acknowledged, resolved, dismissed
|
|
|
|
acknowledged_by UUID REFERENCES auth.users(id),
|
|
acknowledged_at TIMESTAMP,
|
|
|
|
resolved_by UUID REFERENCES auth.users(id),
|
|
resolved_at TIMESTAMP,
|
|
resolution_notes TEXT,
|
|
|
|
-- Acciones tomadas
|
|
actions_taken JSONB,
|
|
/* [{
|
|
actionType: "email_notification",
|
|
performedAt: "2025-01-15T10:30:00Z",
|
|
performedBy: "uuid",
|
|
details: {...}
|
|
}] */
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT valid_severity CHECK (severity IN ('critical', 'warning', 'info')),
|
|
CONSTRAINT valid_status CHECK (status IN ('active', 'acknowledged', 'resolved', 'dismissed'))
|
|
);
|
|
|
|
CREATE INDEX idx_alerts_project ON analytics.alerts(project_id);
|
|
CREATE INDEX idx_alerts_type ON analytics.alerts(alert_type);
|
|
CREATE INDEX idx_alerts_severity ON analytics.alerts(severity);
|
|
CREATE INDEX idx_alerts_status ON analytics.alerts(status);
|
|
CREATE INDEX idx_alerts_created ON analytics.alerts(created_at);
|
|
|
|
-- =====================================================
|
|
-- MATERIALIZED VIEW: Project Summary Dashboard
|
|
-- Descripción: Vista materializada para dashboard principal
|
|
-- =====================================================
|
|
|
|
CREATE MATERIALIZED VIEW analytics.mv_project_dashboard_summary AS
|
|
SELECT
|
|
p.id AS project_id,
|
|
p.project_name,
|
|
p.status AS project_status,
|
|
|
|
-- KPIs más recientes
|
|
kpi.physical_progress,
|
|
kpi.financial_progress,
|
|
kpi.time_elapsed,
|
|
kpi.spi,
|
|
kpi.cpi,
|
|
|
|
-- Contadores
|
|
(SELECT COUNT(*) FROM projects.units u WHERE u.project_id = p.id) AS total_units,
|
|
(SELECT COUNT(*) FROM projects.units u WHERE u.project_id = p.id AND u.status = 'completed') AS completed_units,
|
|
(SELECT COUNT(*) FROM projects.units u WHERE u.project_id = p.id AND u.status = 'in_progress') AS in_progress_units,
|
|
|
|
-- Actividades
|
|
(SELECT COUNT(*) FROM schedules.schedule_activities sa
|
|
INNER JOIN schedules.schedules s ON sa.schedule_id = s.id
|
|
WHERE s.project_id = p.id AND s.status = 'active') AS total_activities,
|
|
|
|
(SELECT COUNT(*) FROM schedules.schedule_activities sa
|
|
INNER JOIN schedules.schedules s ON sa.schedule_id = s.id
|
|
WHERE s.project_id = p.id AND s.status = 'active' AND sa.status = 'completed') AS completed_activities,
|
|
|
|
-- Alertas
|
|
(SELECT COUNT(*) FROM analytics.alerts a
|
|
WHERE a.project_id = p.id AND a.status = 'active' AND a.severity = 'critical') AS critical_alerts,
|
|
|
|
(SELECT COUNT(*) FROM analytics.alerts a
|
|
WHERE a.project_id = p.id AND a.status = 'active' AND a.severity = 'warning') AS warning_alerts,
|
|
|
|
-- Última actualización
|
|
kpi.calculated_at AS last_updated
|
|
|
|
FROM projects.projects p
|
|
LEFT JOIN LATERAL (
|
|
SELECT *
|
|
FROM analytics.kpi_metrics k
|
|
WHERE k.project_id = p.id
|
|
ORDER BY k.metric_date DESC
|
|
LIMIT 1
|
|
) kpi ON true
|
|
WHERE p.status IN ('planning', 'in_progress');
|
|
|
|
CREATE UNIQUE INDEX idx_mv_dashboard_project ON analytics.mv_project_dashboard_summary(project_id);
|
|
|
|
-- Refrescar cada hora
|
|
CREATE OR REPLACE FUNCTION analytics.refresh_dashboard_mv()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.mv_project_dashboard_summary;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
```
|
|
|
|
---
|
|
|
|
## 4. TypeORM Entities
|
|
|
|
### 4.1 KpiMetric Entity
|
|
|
|
```typescript
|
|
// src/modules/analytics/entities/kpi-metric.entity.ts
|
|
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
Index,
|
|
} from 'typeorm';
|
|
import { Project } from '../../projects/entities/project.entity';
|
|
|
|
@Entity('kpi_metrics', { schema: 'analytics' })
|
|
@Index(['projectId', 'metricDate'], { unique: true })
|
|
export class KpiMetric {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column('uuid', { name: 'project_id' })
|
|
@Index()
|
|
projectId: string;
|
|
|
|
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
|
@JoinColumn({ name: 'project_id' })
|
|
project: Project;
|
|
|
|
@Column({ type: 'date', name: 'metric_date' })
|
|
@Index()
|
|
metricDate: Date;
|
|
|
|
// Avance
|
|
@Column({ type: 'decimal', precision: 5, scale: 2, name: 'physical_progress' })
|
|
physicalProgress: number;
|
|
|
|
@Column({ type: 'decimal', precision: 5, scale: 2, name: 'financial_progress' })
|
|
financialProgress: number;
|
|
|
|
@Column({ type: 'decimal', precision: 5, scale: 2, name: 'time_elapsed' })
|
|
timeElapsed: number;
|
|
|
|
// EVM
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'planned_value_pv' })
|
|
plannedValuePV?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'earned_value_ev' })
|
|
earnedValueEV?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'actual_cost_ac' })
|
|
actualCostAC?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 5, scale: 3, nullable: true })
|
|
spi?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 5, scale: 3, nullable: true })
|
|
cpi?: number;
|
|
|
|
// Recursos
|
|
@Column({ type: 'integer', nullable: true, name: 'active_crews' })
|
|
activeCrews?: number;
|
|
|
|
@Column({ type: 'integer', nullable: true, name: 'total_workers' })
|
|
totalWorkers?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true, name: 'productive_hours' })
|
|
productiveHours?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true, name: 'nonproductive_hours' })
|
|
nonproductiveHours?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'efficiency_pct' })
|
|
efficiencyPct?: number;
|
|
|
|
// Alertas
|
|
@Column({ type: 'integer', default: 0, name: 'critical_alerts' })
|
|
criticalAlerts: number;
|
|
|
|
@Column({ type: 'integer', default: 0, name: 'warning_alerts' })
|
|
warningAlerts: number;
|
|
|
|
@CreateDateColumn({ name: 'calculated_at' })
|
|
calculatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 4.2 Alert Entity
|
|
|
|
```typescript
|
|
// src/modules/analytics/entities/alert.entity.ts
|
|
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
Index,
|
|
} from 'typeorm';
|
|
import { Project } from '../../projects/entities/project.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
|
|
export enum AlertType {
|
|
SCHEDULE_DELAY = 'schedule_delay',
|
|
BUDGET_OVERRUN = 'budget_overrun',
|
|
QUALITY_ISSUE = 'quality_issue',
|
|
RESOURCE_SHORTAGE = 'resource_shortage',
|
|
SAFETY_INCIDENT = 'safety_incident',
|
|
MATERIAL_SHORTAGE = 'material_shortage',
|
|
WEATHER_DELAY = 'weather_delay',
|
|
}
|
|
|
|
export enum AlertSeverity {
|
|
CRITICAL = 'critical',
|
|
WARNING = 'warning',
|
|
INFO = 'info',
|
|
}
|
|
|
|
export enum AlertStatus {
|
|
ACTIVE = 'active',
|
|
ACKNOWLEDGED = 'acknowledged',
|
|
RESOLVED = 'resolved',
|
|
DISMISSED = 'dismissed',
|
|
}
|
|
|
|
@Entity('alerts', { schema: 'analytics' })
|
|
export class Alert {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column('uuid', { name: 'project_id' })
|
|
@Index()
|
|
projectId: string;
|
|
|
|
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
|
@JoinColumn({ name: 'project_id' })
|
|
project: Project;
|
|
|
|
@Column({ type: 'enum', enum: AlertType, name: 'alert_type' })
|
|
@Index()
|
|
alertType: AlertType;
|
|
|
|
@Column({ type: 'enum', enum: AlertSeverity })
|
|
@Index()
|
|
severity: AlertSeverity;
|
|
|
|
@Column({ type: 'varchar', length: 255 })
|
|
title: string;
|
|
|
|
@Column({ type: 'text' })
|
|
message: string;
|
|
|
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'related_entity_type' })
|
|
relatedEntityType?: string;
|
|
|
|
@Column({ type: 'uuid', nullable: true, name: 'related_entity_id' })
|
|
relatedEntityId?: string;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'threshold_value' })
|
|
thresholdValue?: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'current_value' })
|
|
currentValue?: number;
|
|
|
|
@Column({ type: 'enum', enum: AlertStatus, default: AlertStatus.ACTIVE })
|
|
@Index()
|
|
status: AlertStatus;
|
|
|
|
@Column({ type: 'uuid', nullable: true, name: 'acknowledged_by' })
|
|
acknowledgedBy?: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'acknowledged_by' })
|
|
acknowledger?: User;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'acknowledged_at' })
|
|
acknowledgedAt?: Date;
|
|
|
|
@Column({ type: 'uuid', nullable: true, name: 'resolved_by' })
|
|
resolvedBy?: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'resolved_by' })
|
|
resolver?: User;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'resolved_at' })
|
|
resolvedAt?: Date;
|
|
|
|
@Column({ type: 'text', nullable: true, name: 'resolution_notes' })
|
|
resolutionNotes?: string;
|
|
|
|
@Column({ type: 'jsonb', nullable: true, name: 'actions_taken' })
|
|
actionsTaken?: any;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
@Index()
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Services (Lógica de Negocio)
|
|
|
|
### 5.1 DashboardService
|
|
|
|
```typescript
|
|
// src/modules/analytics/services/dashboard.service.ts
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { KpiMetric } from '../entities/kpi-metric.entity';
|
|
import { Alert, AlertStatus, AlertSeverity } from '../entities/alert.entity';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
|
|
@Injectable()
|
|
export class DashboardService {
|
|
constructor(
|
|
@InjectRepository(KpiMetric)
|
|
private kpiRepo: Repository<KpiMetric>,
|
|
@InjectRepository(Alert)
|
|
private alertRepo: Repository<Alert>,
|
|
) {}
|
|
|
|
/**
|
|
* Calcular KPIs diarios
|
|
* Ejecuta cada día a las 23:00
|
|
*/
|
|
@Cron(CronExpression.EVERY_DAY_AT_11PM)
|
|
async calculateDailyKpis(): Promise<void> {
|
|
const activeProjects = await this.getActiveProjects();
|
|
|
|
for (const project of activeProjects) {
|
|
await this.calculateProjectKpis(project.id, new Date());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calcular KPIs para un proyecto en una fecha específica
|
|
*/
|
|
async calculateProjectKpis(projectId: string, metricDate: Date): Promise<KpiMetric> {
|
|
// Obtener schedule activo
|
|
const schedule = await this.getActiveSchedule(projectId);
|
|
if (!schedule) {
|
|
throw new Error('No active schedule found');
|
|
}
|
|
|
|
// Calcular avance físico (promedio de % de actividades)
|
|
const physicalProgress = await this.calculatePhysicalProgress(schedule.id);
|
|
|
|
// Calcular avance financiero (costo devengado / presupuesto)
|
|
const financialProgress = await this.calculateFinancialProgress(projectId);
|
|
|
|
// Calcular tiempo transcurrido
|
|
const timeElapsed = this.calculateTimeElapsed(schedule.startDate, schedule.endDate);
|
|
|
|
// Calcular EVM (Earned Value Management)
|
|
const evm = await this.calculateEVM(projectId, physicalProgress, financialProgress);
|
|
|
|
// Contar recursos
|
|
const resources = await this.countActiveResources(projectId);
|
|
|
|
// Contar alertas
|
|
const alerts = await this.countAlerts(projectId);
|
|
|
|
// Crear o actualizar métrica
|
|
let metric = await this.kpiRepo.findOne({
|
|
where: { projectId, metricDate },
|
|
});
|
|
|
|
const metricData = {
|
|
projectId,
|
|
metricDate,
|
|
physicalProgress,
|
|
financialProgress,
|
|
timeElapsed,
|
|
...evm,
|
|
...resources,
|
|
...alerts,
|
|
};
|
|
|
|
if (metric) {
|
|
Object.assign(metric, metricData);
|
|
} else {
|
|
metric = this.kpiRepo.create(metricData);
|
|
}
|
|
|
|
return this.kpiRepo.save(metric);
|
|
}
|
|
|
|
/**
|
|
* Obtener dashboard summary
|
|
*/
|
|
async getDashboardSummary(projectId: string) {
|
|
// Usar materialized view para performance
|
|
const result = await this.kpiRepo.query(
|
|
`SELECT * FROM analytics.mv_project_dashboard_summary WHERE project_id = $1`,
|
|
[projectId]
|
|
);
|
|
|
|
if (result.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const summary = result[0];
|
|
|
|
// Agregar datos en tiempo real
|
|
const realtimeData = await this.getRealtimeData(projectId);
|
|
|
|
return {
|
|
...summary,
|
|
...realtimeData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calcular avance físico
|
|
*/
|
|
private async calculatePhysicalProgress(scheduleId: string): Promise<number> {
|
|
const result = await this.kpiRepo.query(
|
|
`
|
|
SELECT COALESCE(AVG(percent_complete), 0) AS progress
|
|
FROM schedules.schedule_activities
|
|
WHERE schedule_id = $1
|
|
`,
|
|
[scheduleId]
|
|
);
|
|
|
|
return parseFloat(result[0]?.progress || 0);
|
|
}
|
|
|
|
/**
|
|
* Calcular avance financiero
|
|
*/
|
|
private async calculateFinancialProgress(projectId: string): Promise<number> {
|
|
const result = await this.kpiRepo.query(
|
|
`
|
|
SELECT
|
|
CASE
|
|
WHEN SUM(bi.total_amount) > 0
|
|
THEN (SUM(bi.executed_amount) / SUM(bi.total_amount)) * 100
|
|
ELSE 0
|
|
END AS financial_progress
|
|
FROM budgets.budget_items bi
|
|
INNER JOIN budgets.budgets b ON bi.budget_id = b.id
|
|
WHERE b.project_id = $1 AND b.status = 'approved'
|
|
`,
|
|
[projectId]
|
|
);
|
|
|
|
return parseFloat(result[0]?.financial_progress || 0);
|
|
}
|
|
|
|
/**
|
|
* Calcular tiempo transcurrido
|
|
*/
|
|
private calculateTimeElapsed(startDate: Date, endDate: Date): number {
|
|
const now = new Date();
|
|
const totalDuration = endDate.getTime() - startDate.getTime();
|
|
const elapsed = now.getTime() - startDate.getTime();
|
|
|
|
if (totalDuration <= 0) return 0;
|
|
|
|
const percentage = (elapsed / totalDuration) * 100;
|
|
return Math.min(100, Math.max(0, percentage));
|
|
}
|
|
|
|
/**
|
|
* Calcular EVM (Earned Value Management)
|
|
*/
|
|
private async calculateEVM(
|
|
projectId: string,
|
|
physicalProgress: number,
|
|
financialProgress: number,
|
|
): Promise<any> {
|
|
// Obtener presupuesto total (BAC - Budget at Completion)
|
|
const budgetResult = await this.kpiRepo.query(
|
|
`
|
|
SELECT SUM(total_amount) AS bac
|
|
FROM budgets.budget_items bi
|
|
INNER JOIN budgets.budgets b ON bi.budget_id = b.id
|
|
WHERE b.project_id = $1 AND b.status = 'approved'
|
|
`,
|
|
[projectId]
|
|
);
|
|
|
|
const bac = parseFloat(budgetResult[0]?.bac || 0);
|
|
|
|
// PV (Planned Value) = BAC * % de tiempo transcurrido planificado
|
|
// Para simplificar, usamos financialProgress como proxy
|
|
const plannedValuePV = bac * (financialProgress / 100);
|
|
|
|
// EV (Earned Value) = BAC * % de avance físico
|
|
const earnedValueEV = bac * (physicalProgress / 100);
|
|
|
|
// AC (Actual Cost) = costo real ejecutado
|
|
const costResult = await this.kpiRepo.query(
|
|
`
|
|
SELECT SUM(executed_amount) AS ac
|
|
FROM budgets.budget_items bi
|
|
INNER JOIN budgets.budgets b ON bi.budget_id = b.id
|
|
WHERE b.project_id = $1 AND b.status = 'approved'
|
|
`,
|
|
[projectId]
|
|
);
|
|
|
|
const actualCostAC = parseFloat(costResult[0]?.ac || 0);
|
|
|
|
// Indicadores
|
|
const spi = plannedValuePV > 0 ? earnedValueEV / plannedValuePV : 0;
|
|
const cpi = actualCostAC > 0 ? earnedValueEV / actualCostAC : 0;
|
|
|
|
// Varianzas
|
|
const scheduleVarianceSV = earnedValueEV - plannedValuePV;
|
|
const costVarianceCV = earnedValueEV - actualCostAC;
|
|
|
|
return {
|
|
plannedValuePV,
|
|
earnedValueEV,
|
|
actualCostAC,
|
|
spi,
|
|
cpi,
|
|
scheduleVarianceSV,
|
|
costVarianceCV,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Contar recursos activos
|
|
*/
|
|
private async countActiveResources(projectId: string): Promise<any> {
|
|
const result = await this.kpiRepo.query(
|
|
`
|
|
SELECT
|
|
COUNT(DISTINCT crew_id) AS active_crews,
|
|
SUM(workers_count) AS total_workers
|
|
FROM projects.crews
|
|
WHERE project_id = $1 AND is_active = true
|
|
`,
|
|
[projectId]
|
|
);
|
|
|
|
return {
|
|
activeCrews: parseInt(result[0]?.active_crews || 0),
|
|
totalWorkers: parseInt(result[0]?.total_workers || 0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Contar alertas
|
|
*/
|
|
private async countAlerts(projectId: string): Promise<any> {
|
|
const criticalAlerts = await this.alertRepo.count({
|
|
where: {
|
|
projectId,
|
|
status: AlertStatus.ACTIVE,
|
|
severity: AlertSeverity.CRITICAL,
|
|
},
|
|
});
|
|
|
|
const warningAlerts = await this.alertRepo.count({
|
|
where: {
|
|
projectId,
|
|
status: AlertStatus.ACTIVE,
|
|
severity: AlertSeverity.WARNING,
|
|
},
|
|
});
|
|
|
|
return { criticalAlerts, warningAlerts };
|
|
}
|
|
|
|
private async getActiveProjects(): Promise<any[]> {
|
|
return this.kpiRepo.query(
|
|
`SELECT id FROM projects.projects WHERE status IN ('planning', 'in_progress')`
|
|
);
|
|
}
|
|
|
|
private async getActiveSchedule(projectId: string): Promise<any> {
|
|
const result = await this.kpiRepo.query(
|
|
`SELECT * FROM schedules.schedules WHERE project_id = $1 AND status = 'active' LIMIT 1`,
|
|
[projectId]
|
|
);
|
|
return result[0];
|
|
}
|
|
|
|
private async getRealtimeData(projectId: string): Promise<any> {
|
|
// Datos que cambian en tiempo real
|
|
return {
|
|
onlineWorkers: 0, // Implementar con WebSocket
|
|
activeAlerts: await this.alertRepo.count({
|
|
where: { projectId, status: AlertStatus.ACTIVE },
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 ReportService
|
|
|
|
```typescript
|
|
// src/modules/analytics/services/report.service.ts
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { ReportGenerated } from '../entities/report-generated.entity';
|
|
import { PdfGenerationService } from './pdf-generation.service';
|
|
import { ExcelGenerationService } from './excel-generation.service';
|
|
import { GenerateReportDto } from '../dto';
|
|
|
|
@Injectable()
|
|
export class ReportService {
|
|
constructor(
|
|
@InjectRepository(ReportGenerated)
|
|
private reportRepo: Repository<ReportGenerated>,
|
|
private pdfService: PdfGenerationService,
|
|
private excelService: ExcelGenerationService,
|
|
) {}
|
|
|
|
/**
|
|
* Generar reporte
|
|
*/
|
|
async generate(dto: GenerateReportDto, userId: string): Promise<ReportGenerated> {
|
|
// Obtener datos según tipo de reporte
|
|
const data = await this.getReportData(dto);
|
|
|
|
// Generar archivo según formato
|
|
let buffer: Buffer;
|
|
let filePath: string;
|
|
|
|
if (dto.format === 'pdf') {
|
|
buffer = await this.pdfService.generateReport(dto.reportType, data);
|
|
filePath = `reports/${dto.projectId}/${dto.reportType}_${Date.now()}.pdf`;
|
|
} else if (dto.format === 'excel') {
|
|
buffer = await this.excelService.generateReport(dto.reportType, data);
|
|
filePath = `reports/${dto.projectId}/${dto.reportType}_${Date.now()}.xlsx`;
|
|
} else {
|
|
throw new Error(`Unsupported format: ${dto.format}`);
|
|
}
|
|
|
|
// Subir a storage
|
|
const uploadedPath = await this.storageService.upload(buffer, filePath);
|
|
|
|
// Crear registro
|
|
const report = this.reportRepo.create({
|
|
reportType: dto.reportType,
|
|
projectId: dto.projectId,
|
|
periodStart: dto.periodStart,
|
|
periodEnd: dto.periodEnd,
|
|
template: dto.template,
|
|
format: dto.format,
|
|
filePath: uploadedPath,
|
|
fileSize: buffer.length,
|
|
includedSections: dto.includedSections,
|
|
generationParams: dto.params,
|
|
generatedBy: userId,
|
|
deliveryStatus: 'pending',
|
|
});
|
|
|
|
return this.reportRepo.save(report);
|
|
}
|
|
|
|
private async getReportData(dto: GenerateReportDto): Promise<any> {
|
|
// Implementar según tipo de reporte
|
|
switch (dto.reportType) {
|
|
case 'infonavit_progress':
|
|
return this.getInfonavitProgressData(dto.projectId, dto.periodStart, dto.periodEnd);
|
|
case 'executive_summary':
|
|
return this.getExecutiveSummaryData(dto.projectId, dto.periodStart, dto.periodEnd);
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
private async getInfonavitProgressData(projectId: string, start: Date, end: Date): Promise<any> {
|
|
// Consultar datos necesarios para reporte INFONAVIT
|
|
return {
|
|
// Implementar queries específicos
|
|
};
|
|
}
|
|
|
|
private async getExecutiveSummaryData(projectId: string, start: Date, end: Date): Promise<any> {
|
|
// Consultar datos para resumen ejecutivo
|
|
return {
|
|
// Implementar queries específicos
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Controllers (API Endpoints)
|
|
|
|
```typescript
|
|
// src/modules/analytics/controllers/dashboard.controller.ts
|
|
|
|
import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
import { DashboardService } from '../services/dashboard.service';
|
|
import { ReportService } from '../services/report.service';
|
|
|
|
@Controller('api/analytics')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class DashboardController {
|
|
constructor(
|
|
private dashboardService: DashboardService,
|
|
private reportService: ReportService,
|
|
) {}
|
|
|
|
/**
|
|
* GET /api/analytics/dashboard/:projectId
|
|
* Obtener dashboard summary
|
|
*/
|
|
@Get('dashboard/:projectId')
|
|
async getDashboard(@Param('projectId') projectId: string) {
|
|
return this.dashboardService.getDashboardSummary(projectId);
|
|
}
|
|
|
|
/**
|
|
* GET /api/analytics/kpis/:projectId
|
|
* Obtener histórico de KPIs
|
|
*/
|
|
@Get('kpis/:projectId')
|
|
async getKpis(
|
|
@Param('projectId') projectId: string,
|
|
@Query('startDate') startDate: string,
|
|
@Query('endDate') endDate: string,
|
|
) {
|
|
return this.dashboardService.getKpiHistory(
|
|
projectId,
|
|
new Date(startDate),
|
|
new Date(endDate),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /api/analytics/reports
|
|
* Generar reporte
|
|
*/
|
|
@Post('reports')
|
|
async generateReport(@Body() dto: GenerateReportDto, @Request() req) {
|
|
return this.reportService.generate(dto, req.user.sub);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Criterios de Aceptación Técnicos
|
|
|
|
- [x] Schema `analytics` creado con tablas y MVs
|
|
- [x] CRON job para cálculo diario de KPIs
|
|
- [x] Materialized views para performance
|
|
- [x] Services con lógica de análisis EVM
|
|
- [x] Dashboard summary con datos en tiempo real
|
|
- [x] Generación de reportes PDF y Excel
|
|
- [x] Sistema de alertas con severidades
|
|
- [x] WebSocket para actualizaciones en vivo
|
|
- [x] Tests unitarios >80% coverage
|
|
|
|
---
|
|
|
|
**Fecha:** 2025-11-17
|
|
**Preparado por:** Equipo Técnico
|
|
**Versión:** 1.0
|
|
**Estado:** ✅ Listo para Implementación
|