# SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN ## Metadatos | Campo | Valor | |-------|-------| | **Código** | SPEC-TRANS-011 | | **Versión** | 1.0.0 | | **Fecha** | 2025-01-15 | | **Autor** | Requirements-Analyst Agent | | **Estado** | DRAFT | | **Prioridad** | P1 | | **Módulos Afectados** | MGN-011 (Proyectos y Tareas) | | **Gaps Cubiertos** | GAP-MGN-011-001, GAP-MGN-011-002 | ## Resumen Ejecutivo Esta especificación define la implementación de dos funcionalidades críticas para la gestión de proyectos: 1. **Dependencias entre Tareas**: Sistema de relaciones entre tareas que permite definir qué tareas deben completarse antes de iniciar otras (Finish-to-Start), con validación de ciclos y gestión automática de estados bloqueados. 2. **Burndown Charts**: Visualización del progreso del proyecto comparando trabajo estimado vs. trabajo restante a lo largo del tiempo, esencial para metodologías ágiles. ### Referencia Odoo 18 Basado en análisis del módulo `project` de Odoo 18: - **project.task**: Campos `depend_on_ids` y `dependent_ids` (Many2many) - **project.task.burndown.chart.report**: Modelo SQL para reportes de burndown - **Tipo de dependencia**: Solo Finish-to-Start (simplificado) --- ## Parte 1: Dependencias entre Tareas ### 1.1 Análisis del Gap **GAP-MGN-011-001**: El sistema actual no soporta definir dependencias entre tareas del proyecto. | Aspecto | Situación Actual | Situación Objetivo | |---------|------------------|-------------------| | Relaciones | Sin dependencias | Dependencias F2S con validación | | Estados | Estados independientes | Bloqueo automático por dependencias | | Visualización | Sin indicadores | Indicadores visuales de bloqueo | | Validación | N/A | Detección de ciclos | ### 1.2 Modelo de Datos #### 1.2.1 Tipos Enumerados ```sql -- Tipo de dependencia (extensible para futuras relaciones) CREATE TYPE task_dependency_type AS ENUM ( 'finish_to_start', -- La tarea bloqueada inicia cuando la bloqueante termina 'start_to_start', -- Ambas inician juntas (futuro) 'finish_to_finish', -- Ambas terminan juntas (futuro) 'start_to_finish' -- La bloqueada termina cuando la bloqueante inicia (futuro) ); -- Estado de tarea extendido con waiting CREATE TYPE task_state_extended AS ENUM ( '01_in_progress', -- En progreso '02_changes_requested', -- Cambios solicitados '03_approved', -- Aprobada '04_waiting_normal', -- Bloqueada esperando dependencias '1_done', -- Completada '1_canceled' -- Cancelada ); -- Estado de bloqueo de dependencia CREATE TYPE dependency_block_status AS ENUM ( 'blocked', -- Dependencia no satisfecha 'ready', -- Dependencia satisfecha 'overridden' -- Bloqueo anulado manualmente ); ``` #### 1.2.2 Tabla de Dependencias ```sql -- Relación many-to-many para dependencias entre tareas CREATE TABLE project_task_dependencies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relación de dependencia task_id UUID NOT NULL, -- Tarea que depende (bloqueada) depends_on_task_id UUID NOT NULL, -- Tarea de la que depende (bloqueante) -- Tipo de dependencia dependency_type task_dependency_type NOT NULL DEFAULT 'finish_to_start', -- Estado de la dependencia block_status dependency_block_status NOT NULL DEFAULT 'blocked', -- Lag time (opcional para futuro) lag_days INTEGER DEFAULT 0, -- Días de espera después de completar dependencia -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL, -- Constraints CONSTRAINT fk_task FOREIGN KEY (task_id) REFERENCES project_tasks(id) ON DELETE CASCADE, CONSTRAINT fk_depends_on FOREIGN KEY (depends_on_task_id) REFERENCES project_tasks(id) ON DELETE CASCADE, CONSTRAINT chk_no_self_dependency CHECK (task_id != depends_on_task_id), CONSTRAINT uq_task_dependency UNIQUE (task_id, depends_on_task_id) ); -- Índices para consultas eficientes CREATE INDEX idx_task_dependencies_task ON project_task_dependencies(task_id); CREATE INDEX idx_task_dependencies_depends_on ON project_task_dependencies(depends_on_task_id); CREATE INDEX idx_task_dependencies_status ON project_task_dependencies(block_status); COMMENT ON TABLE project_task_dependencies IS 'Relaciones de dependencia entre tareas de proyecto. Implementa Finish-to-Start por defecto.'; ``` #### 1.2.3 Extensión de Tabla de Tareas ```sql -- Agregar campos computados a project_tasks ALTER TABLE project_tasks ADD COLUMN IF NOT EXISTS is_blocked BOOLEAN GENERATED ALWAYS AS ( state = '04_waiting_normal' ) STORED; -- Vista materializada para dependencias activas CREATE MATERIALIZED VIEW mv_task_dependency_status AS SELECT t.id AS task_id, t.project_id, t.name AS task_name, t.state, COUNT(d.id) AS total_dependencies, COUNT(d.id) FILTER (WHERE d.block_status = 'blocked') AS blocked_dependencies, COUNT(d.id) FILTER (WHERE d.block_status = 'ready') AS ready_dependencies, BOOL_OR(d.block_status = 'blocked') AS has_blocking_dependencies, ARRAY_AGG(DISTINCT dt.name) FILTER (WHERE d.block_status = 'blocked') AS blocking_task_names FROM project_tasks t LEFT JOIN project_task_dependencies d ON d.task_id = t.id LEFT JOIN project_tasks dt ON dt.id = d.depends_on_task_id GROUP BY t.id, t.project_id, t.name, t.state; CREATE UNIQUE INDEX idx_mv_task_dep_status ON mv_task_dependency_status(task_id); -- Función para refrescar vista CREATE OR REPLACE FUNCTION refresh_task_dependency_status() RETURNS TRIGGER AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY mv_task_dependency_status; RETURN NULL; END; $$ LANGUAGE plpgsql; ``` #### 1.2.4 Validación de Ciclos ```sql -- Función recursiva para detectar ciclos en dependencias CREATE OR REPLACE FUNCTION check_dependency_cycle( p_task_id UUID, p_depends_on_id UUID ) RETURNS BOOLEAN AS $$ DECLARE v_has_cycle BOOLEAN; BEGIN -- Verificar si agregar esta dependencia crearía un ciclo WITH RECURSIVE dependency_chain AS ( -- Caso base: la tarea de la que dependeríamos SELECT depends_on_task_id AS task_id, 1 AS depth FROM project_task_dependencies WHERE task_id = p_depends_on_id UNION ALL -- Caso recursivo: seguir la cadena SELECT d.depends_on_task_id, dc.depth + 1 FROM project_task_dependencies d INNER JOIN dependency_chain dc ON dc.task_id = d.task_id WHERE dc.depth < 100 -- Límite de profundidad para evitar loops infinitos ) SELECT EXISTS ( SELECT 1 FROM dependency_chain WHERE task_id = p_task_id ) INTO v_has_cycle; RETURN v_has_cycle; END; $$ LANGUAGE plpgsql; -- Constraint de validación usando trigger CREATE OR REPLACE FUNCTION validate_dependency_no_cycle() RETURNS TRIGGER AS $$ BEGIN IF check_dependency_cycle(NEW.task_id, NEW.depends_on_task_id) THEN RAISE EXCEPTION 'Adding this dependency would create a cycle' USING ERRCODE = 'check_violation', HINT = 'Task dependencies cannot form circular references'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_validate_dependency_cycle BEFORE INSERT OR UPDATE ON project_task_dependencies FOR EACH ROW EXECUTE FUNCTION validate_dependency_no_cycle(); ``` #### 1.2.5 Actualización Automática de Estados ```sql -- Función para actualizar estado de tarea basado en dependencias CREATE OR REPLACE FUNCTION update_task_blocked_state() RETURNS TRIGGER AS $$ DECLARE v_has_unmet_dependencies BOOLEAN; v_dependent_task project_tasks%ROWTYPE; BEGIN -- Cuando una tarea se completa, verificar tareas dependientes IF TG_OP = 'UPDATE' AND NEW.state IN ('1_done', '03_approved') AND OLD.state NOT IN ('1_done', '03_approved') THEN -- Actualizar estado de dependencias UPDATE project_task_dependencies SET block_status = 'ready', updated_at = NOW(), updated_by = NEW.updated_by WHERE depends_on_task_id = NEW.id AND block_status = 'blocked'; -- Verificar y actualizar tareas que dependían de esta FOR v_dependent_task IN SELECT t.* FROM project_tasks t INNER JOIN project_task_dependencies d ON d.task_id = t.id WHERE d.depends_on_task_id = NEW.id AND t.state = '04_waiting_normal' LOOP -- Verificar si ya no tiene dependencias bloqueantes SELECT EXISTS ( SELECT 1 FROM project_task_dependencies WHERE task_id = v_dependent_task.id AND block_status = 'blocked' ) INTO v_has_unmet_dependencies; -- Si no tiene más bloqueos, cambiar a in_progress IF NOT v_has_unmet_dependencies THEN UPDATE project_tasks SET state = '01_in_progress', updated_at = NOW(), updated_by = NEW.updated_by WHERE id = v_dependent_task.id; END IF; END LOOP; -- Cuando una tarea completada vuelve a estado anterior ELSIF TG_OP = 'UPDATE' AND OLD.state IN ('1_done', '03_approved') AND NEW.state NOT IN ('1_done', '03_approved') THEN -- Re-bloquear dependencias UPDATE project_task_dependencies SET block_status = 'blocked', updated_at = NOW(), updated_by = NEW.updated_by WHERE depends_on_task_id = NEW.id; -- Bloquear tareas dependientes UPDATE project_tasks SET state = '04_waiting_normal', updated_at = NOW(), updated_by = NEW.updated_by WHERE id IN ( SELECT task_id FROM project_task_dependencies WHERE depends_on_task_id = NEW.id ) AND state = '01_in_progress'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_update_blocked_state AFTER UPDATE OF state ON project_tasks FOR EACH ROW EXECUTE FUNCTION update_task_blocked_state(); ``` ### 1.3 Interfaces de Dominio #### 1.3.1 Entidades ```typescript // src/modules/projects/domain/entities/task-dependency.entity.ts import { Entity, AggregateRoot } from '@shared/domain'; export enum DependencyType { FINISH_TO_START = 'finish_to_start', START_TO_START = 'start_to_start', FINISH_TO_FINISH = 'finish_to_finish', START_TO_FINISH = 'start_to_finish' } export enum DependencyBlockStatus { BLOCKED = 'blocked', READY = 'ready', OVERRIDDEN = 'overridden' } export interface TaskDependencyProps { id: string; taskId: string; dependsOnTaskId: string; dependencyType: DependencyType; blockStatus: DependencyBlockStatus; lagDays: number; createdAt: Date; createdBy: string; updatedAt: Date; updatedBy: string; } export class TaskDependency extends Entity { get taskId(): string { return this.props.taskId; } get dependsOnTaskId(): string { return this.props.dependsOnTaskId; } get isBlocked(): boolean { return this.props.blockStatus === DependencyBlockStatus.BLOCKED; } get isReady(): boolean { return this.props.blockStatus === DependencyBlockStatus.READY; } public markAsReady(userId: string): void { if (this.props.blockStatus === DependencyBlockStatus.BLOCKED) { this.props.blockStatus = DependencyBlockStatus.READY; this.props.updatedAt = new Date(); this.props.updatedBy = userId; } } public markAsBlocked(userId: string): void { if (this.props.blockStatus !== DependencyBlockStatus.OVERRIDDEN) { this.props.blockStatus = DependencyBlockStatus.BLOCKED; this.props.updatedAt = new Date(); this.props.updatedBy = userId; } } public override(userId: string): void { this.props.blockStatus = DependencyBlockStatus.OVERRIDDEN; this.props.updatedAt = new Date(); this.props.updatedBy = userId; } public static create(props: Omit): TaskDependency { return new TaskDependency({ ...props, id: crypto.randomUUID(), blockStatus: DependencyBlockStatus.BLOCKED, createdAt: new Date(), updatedAt: new Date() }); } } ``` #### 1.3.2 Extensión de Task Entity ```typescript // Extensión para src/modules/projects/domain/entities/task.entity.ts export enum TaskStateExtended { IN_PROGRESS = '01_in_progress', CHANGES_REQUESTED = '02_changes_requested', APPROVED = '03_approved', WAITING_NORMAL = '04_waiting_normal', // Bloqueada DONE = '1_done', CANCELED = '1_canceled' } // Agregar a TaskProps interface TaskPropsExtended { // ... props existentes dependsOnIds: string[]; // IDs de tareas de las que depende dependentIds: string[]; // IDs de tareas que dependen de esta isBlocked: boolean; // Computado } // Métodos adicionales para Task export class Task extends AggregateRoot { get isBlocked(): boolean { return this.props.state === TaskStateExtended.WAITING_NORMAL; } get canStart(): boolean { return !this.isBlocked && this.props.state === TaskStateExtended.IN_PROGRESS; } public addDependency(dependsOnTaskId: string): void { if (dependsOnTaskId === this.id) { throw new DomainError('A task cannot depend on itself'); } if (!this.props.dependsOnIds.includes(dependsOnTaskId)) { this.props.dependsOnIds.push(dependsOnTaskId); this.addDomainEvent(new TaskDependencyAddedEvent(this.id, dependsOnTaskId)); } } public removeDependency(dependsOnTaskId: string): void { const index = this.props.dependsOnIds.indexOf(dependsOnTaskId); if (index > -1) { this.props.dependsOnIds.splice(index, 1); this.addDomainEvent(new TaskDependencyRemovedEvent(this.id, dependsOnTaskId)); } } public block(): void { if (this.props.state === TaskStateExtended.IN_PROGRESS) { this.props.state = TaskStateExtended.WAITING_NORMAL; this.addDomainEvent(new TaskBlockedEvent(this.id)); } } public unblock(): void { if (this.props.state === TaskStateExtended.WAITING_NORMAL) { this.props.state = TaskStateExtended.IN_PROGRESS; this.addDomainEvent(new TaskUnblockedEvent(this.id)); } } } ``` #### 1.3.3 Domain Service ```typescript // src/modules/projects/domain/services/task-dependency.service.ts import { Injectable } from '@nestjs/common'; import { TaskRepository } from '../repositories/task.repository'; import { TaskDependencyRepository } from '../repositories/task-dependency.repository'; export interface DependencyValidationResult { isValid: boolean; hasCycle: boolean; cyclePath?: string[]; error?: string; } @Injectable() export class TaskDependencyService { constructor( private readonly taskRepository: TaskRepository, private readonly dependencyRepository: TaskDependencyRepository ) {} /** * Validar que agregar una dependencia no crea ciclos */ async validateDependency( taskId: string, dependsOnTaskId: string ): Promise { // Verificar que ambas tareas existen const [task, dependsOnTask] = await Promise.all([ this.taskRepository.findById(taskId), this.taskRepository.findById(dependsOnTaskId) ]); if (!task || !dependsOnTask) { return { isValid: false, hasCycle: false, error: 'One or both tasks not found' }; } // Verificar que están en el mismo proyecto if (task.projectId !== dependsOnTask.projectId) { return { isValid: false, hasCycle: false, error: 'Tasks must be in the same project' }; } // Verificar ciclos const cyclePath = await this.detectCycle(taskId, dependsOnTaskId); if (cyclePath.length > 0) { return { isValid: false, hasCycle: true, cyclePath, error: 'Adding this dependency would create a circular reference' }; } return { isValid: true, hasCycle: false }; } /** * Detectar ciclos usando DFS */ private async detectCycle( taskId: string, dependsOnTaskId: string ): Promise { const visited = new Set(); const path: string[] = []; const dfs = async (currentId: string): Promise => { if (currentId === taskId) { return true; // Ciclo encontrado } if (visited.has(currentId)) { return false; } visited.add(currentId); path.push(currentId); const dependencies = await this.dependencyRepository.findByTaskId(currentId); for (const dep of dependencies) { if (await dfs(dep.dependsOnTaskId)) { return true; } } path.pop(); return false; }; const hasCycle = await dfs(dependsOnTaskId); return hasCycle ? [...path, taskId] : []; } /** * Agregar dependencia con validación */ async addDependency( taskId: string, dependsOnTaskId: string, userId: string ): Promise { const validation = await this.validateDependency(taskId, dependsOnTaskId); if (!validation.isValid) { throw new DomainError(validation.error!); } // Verificar estado de la tarea bloqueante const dependsOnTask = await this.taskRepository.findById(dependsOnTaskId); const isBlocked = !['1_done', '03_approved'].includes(dependsOnTask.state); const dependency = TaskDependency.create({ taskId, dependsOnTaskId, dependencyType: DependencyType.FINISH_TO_START, lagDays: 0, createdBy: userId, updatedBy: userId }); if (!isBlocked) { dependency.markAsReady(userId); } await this.dependencyRepository.save(dependency); // Actualizar estado de la tarea si es necesario if (isBlocked) { const task = await this.taskRepository.findById(taskId); if (task.state === TaskStateExtended.IN_PROGRESS) { task.block(); await this.taskRepository.save(task); } } return dependency; } /** * Remover dependencia */ async removeDependency( taskId: string, dependsOnTaskId: string, userId: string ): Promise { await this.dependencyRepository.delete(taskId, dependsOnTaskId); // Verificar si la tarea puede desbloquearse const remainingBlocked = await this.dependencyRepository.countBlocked(taskId); if (remainingBlocked === 0) { const task = await this.taskRepository.findById(taskId); if (task.state === TaskStateExtended.WAITING_NORMAL) { task.unblock(); await this.taskRepository.save(task); } } } /** * Propagar cambio de estado de tarea */ async propagateTaskStateChange( taskId: string, newState: TaskStateExtended, userId: string ): Promise { const isCompleted = ['1_done', '03_approved'].includes(newState); if (isCompleted) { // Marcar dependencias como ready await this.dependencyRepository.markDependenciesReady(taskId, userId); // Verificar y desbloquear tareas dependientes const dependentTasks = await this.dependencyRepository.findDependentTasks(taskId); for (const depTask of dependentTasks) { const hasBlockingDeps = await this.dependencyRepository.hasBlockingDependencies(depTask.id); if (!hasBlockingDeps && depTask.state === TaskStateExtended.WAITING_NORMAL) { depTask.unblock(); await this.taskRepository.save(depTask); } } } else { // Re-bloquear si la tarea vuelve a estado incompleto await this.dependencyRepository.markDependenciesBlocked(taskId, userId); // Bloquear tareas dependientes en progreso const dependentTasks = await this.dependencyRepository.findDependentTasks(taskId); for (const depTask of dependentTasks) { if (depTask.state === TaskStateExtended.IN_PROGRESS) { depTask.block(); await this.taskRepository.save(depTask); } } } } /** * Obtener árbol de dependencias de una tarea */ async getDependencyTree(taskId: string): Promise { return this.buildDependencyTree(taskId, new Set()); } private async buildDependencyTree( taskId: string, visited: Set ): Promise { if (visited.has(taskId)) { throw new Error('Cycle detected in dependency tree'); } visited.add(taskId); const task = await this.taskRepository.findById(taskId); const dependencies = await this.dependencyRepository.findByTaskId(taskId); const children: DependencyTreeNode[] = []; for (const dep of dependencies) { children.push(await this.buildDependencyTree(dep.dependsOnTaskId, visited)); } visited.delete(taskId); return { taskId, taskName: task.name, state: task.state, isBlocked: task.isBlocked, dependencies: children }; } } interface DependencyTreeNode { taskId: string; taskName: string; state: TaskStateExtended; isBlocked: boolean; dependencies: DependencyTreeNode[]; } ``` ### 1.4 API REST ```typescript // src/modules/projects/interfaces/http/task-dependencies.controller.ts @Controller('projects/:projectId/tasks/:taskId/dependencies') @ApiTags('Task Dependencies') export class TaskDependenciesController { constructor( private readonly dependencyService: TaskDependencyService ) {} @Get() @ApiOperation({ summary: 'Get task dependencies' }) async getDependencies( @Param('taskId') taskId: string ): Promise { return this.dependencyService.getTaskDependencies(taskId); } @Post() @ApiOperation({ summary: 'Add dependency to task' }) async addDependency( @Param('taskId') taskId: string, @Body() dto: AddDependencyDto, @CurrentUser() user: User ): Promise { return this.dependencyService.addDependency( taskId, dto.dependsOnTaskId, user.id ); } @Delete(':dependsOnTaskId') @ApiOperation({ summary: 'Remove dependency' }) @HttpCode(HttpStatus.NO_CONTENT) async removeDependency( @Param('taskId') taskId: string, @Param('dependsOnTaskId') dependsOnTaskId: string, @CurrentUser() user: User ): Promise { await this.dependencyService.removeDependency(taskId, dependsOnTaskId, user.id); } @Post(':dependsOnTaskId/override') @ApiOperation({ summary: 'Override blocking dependency' }) async overrideDependency( @Param('taskId') taskId: string, @Param('dependsOnTaskId') dependsOnTaskId: string, @CurrentUser() user: User ): Promise { return this.dependencyService.overrideDependency(taskId, dependsOnTaskId, user.id); } @Get('tree') @ApiOperation({ summary: 'Get dependency tree' }) async getDependencyTree( @Param('taskId') taskId: string ): Promise { return this.dependencyService.getDependencyTree(taskId); } @Post('validate') @ApiOperation({ summary: 'Validate potential dependency' }) async validateDependency( @Param('taskId') taskId: string, @Body() dto: AddDependencyDto ): Promise { return this.dependencyService.validateDependency(taskId, dto.dependsOnTaskId); } } // DTOs class AddDependencyDto { @IsUUID() @ApiProperty({ description: 'ID of the task this task depends on' }) dependsOnTaskId: string; } class TaskDependencyDto { id: string; taskId: string; dependsOnTaskId: string; dependsOnTaskName: string; dependsOnTaskState: string; dependencyType: string; blockStatus: string; isBlocked: boolean; } ``` --- ## Parte 2: Burndown Charts ### 2.1 Análisis del Gap **GAP-MGN-011-002**: No existe visualización de progreso tipo burndown chart para proyectos. | Aspecto | Situación Actual | Situación Objetivo | |---------|------------------|-------------------| | Visualización | Sin burndown | Gráficos de burndown por proyecto | | Datos | Sin tracking histórico | Snapshots diarios de horas/tareas | | Métricas | Solo estado actual | Horas estimadas vs. restantes | | Reportes | Básicos | Burndown con tendencias | ### 2.2 Modelo de Datos #### 2.2.1 Tablas de Snapshots ```sql -- Snapshot diario del estado del proyecto CREATE TABLE project_burndown_snapshots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -- Fecha del snapshot snapshot_date DATE NOT NULL, -- Métricas de horas total_allocated_hours DECIMAL(10,2) NOT NULL DEFAULT 0, -- Horas totales estimadas remaining_hours DECIMAL(10,2) NOT NULL DEFAULT 0, -- Horas restantes completed_hours DECIMAL(10,2) NOT NULL DEFAULT 0, -- Horas completadas -- Métricas de tareas total_tasks INTEGER NOT NULL DEFAULT 0, completed_tasks INTEGER NOT NULL DEFAULT 0, remaining_tasks INTEGER NOT NULL DEFAULT 0, blocked_tasks INTEGER NOT NULL DEFAULT 0, -- Métricas de story points (opcional para ágil) total_story_points DECIMAL(6,2) DEFAULT 0, completed_story_points DECIMAL(6,2) DEFAULT 0, -- Metadatos created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraint único por proyecto y fecha CONSTRAINT uq_project_snapshot_date UNIQUE (project_id, snapshot_date) ); -- Índices CREATE INDEX idx_burndown_project_date ON project_burndown_snapshots(project_id, snapshot_date); CREATE INDEX idx_burndown_date ON project_burndown_snapshots(snapshot_date); -- Snapshot por milestone/sprint (opcional) CREATE TABLE milestone_burndown_snapshots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), milestone_id UUID NOT NULL REFERENCES project_milestones(id) ON DELETE CASCADE, snapshot_date DATE NOT NULL, total_allocated_hours DECIMAL(10,2) NOT NULL DEFAULT 0, remaining_hours DECIMAL(10,2) NOT NULL DEFAULT 0, completed_hours DECIMAL(10,2) NOT NULL DEFAULT 0, total_tasks INTEGER NOT NULL DEFAULT 0, completed_tasks INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_milestone_snapshot_date UNIQUE (milestone_id, snapshot_date) ); COMMENT ON TABLE project_burndown_snapshots IS 'Snapshots diarios para generar gráficos de burndown por proyecto'; ``` #### 2.2.2 Vista de Reporte Burndown ```sql -- Vista materializada para reporte de burndown CREATE MATERIALIZED VIEW mv_project_burndown_report AS WITH date_series AS ( -- Generar serie de fechas desde inicio hasta hoy SELECT p.id AS project_id, generate_series( p.date_start::date, LEAST(p.date_end::date, CURRENT_DATE), '1 day'::interval )::date AS report_date FROM projects p WHERE p.date_start IS NOT NULL ), daily_metrics AS ( SELECT ds.project_id, ds.report_date, COALESCE(s.total_allocated_hours, 0) AS total_hours, COALESCE(s.remaining_hours, 0) AS remaining_hours, COALESCE(s.completed_hours, 0) AS completed_hours, COALESCE(s.total_tasks, 0) AS total_tasks, COALESCE(s.completed_tasks, 0) AS completed_tasks, COALESCE(s.blocked_tasks, 0) AS blocked_tasks FROM date_series ds LEFT JOIN project_burndown_snapshots s ON s.project_id = ds.project_id AND s.snapshot_date = ds.report_date ), with_ideal AS ( SELECT dm.*, -- Calcular línea ideal de burndown p.date_start, p.date_end, EXTRACT(DAY FROM (p.date_end - p.date_start)) AS total_days, EXTRACT(DAY FROM (dm.report_date - p.date_start)) AS elapsed_days, -- Horas ideales restantes (línea recta desde total hasta 0) CASE WHEN p.date_end = p.date_start THEN 0 ELSE GREATEST(0, (SELECT MAX(total_allocated_hours) FROM project_burndown_snapshots WHERE project_id = dm.project_id) * (1 - EXTRACT(DAY FROM (dm.report_date - p.date_start))::DECIMAL / NULLIF(EXTRACT(DAY FROM (p.date_end - p.date_start)), 0)) ) END AS ideal_remaining_hours FROM daily_metrics dm JOIN projects p ON p.id = dm.project_id ) SELECT project_id, report_date, total_hours, remaining_hours, completed_hours, total_tasks, completed_tasks, blocked_tasks, ideal_remaining_hours, -- Varianza respecto a línea ideal remaining_hours - ideal_remaining_hours AS variance_hours, -- Porcentaje de completado CASE WHEN total_hours > 0 THEN ROUND((completed_hours / total_hours * 100)::NUMERIC, 2) ELSE 0 END AS completion_percentage, -- Velocidad (horas completadas por día - promedio móvil 7 días) AVG(completed_hours) OVER ( PARTITION BY project_id ORDER BY report_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS avg_daily_velocity FROM with_ideal ORDER BY project_id, report_date; CREATE UNIQUE INDEX idx_mv_burndown_report ON mv_project_burndown_report(project_id, report_date); -- Función para refrescar reporte CREATE OR REPLACE FUNCTION refresh_burndown_report() RETURNS void AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY mv_project_burndown_report; END; $$ LANGUAGE plpgsql; ``` #### 2.2.3 Job de Snapshot Diario ```sql -- Función para crear snapshot diario de todos los proyectos activos CREATE OR REPLACE FUNCTION create_daily_burndown_snapshots() RETURNS INTEGER AS $$ DECLARE v_count INTEGER := 0; v_project RECORD; BEGIN FOR v_project IN SELECT p.id AS project_id, COALESCE(SUM(t.allocated_hours), 0) AS total_allocated, COALESCE(SUM(t.allocated_hours) FILTER (WHERE t.state IN ('1_done', '03_approved')), 0) AS completed, COALESCE(SUM(t.allocated_hours) FILTER (WHERE t.state NOT IN ('1_done', '03_approved', '1_canceled')), 0) AS remaining, COUNT(t.id) AS total_tasks, COUNT(t.id) FILTER (WHERE t.state IN ('1_done', '03_approved')) AS done_tasks, COUNT(t.id) FILTER (WHERE t.state = '04_waiting_normal') AS blocked FROM projects p LEFT JOIN project_tasks t ON t.project_id = p.id AND t.is_active = true WHERE p.is_active = true AND p.state NOT IN ('done', 'canceled') GROUP BY p.id LOOP INSERT INTO project_burndown_snapshots ( project_id, snapshot_date, total_allocated_hours, remaining_hours, completed_hours, total_tasks, completed_tasks, remaining_tasks, blocked_tasks ) VALUES ( v_project.project_id, CURRENT_DATE, v_project.total_allocated, v_project.remaining, v_project.completed, v_project.total_tasks, v_project.done_tasks, v_project.total_tasks - v_project.done_tasks, v_project.blocked ) ON CONFLICT (project_id, snapshot_date) DO UPDATE SET total_allocated_hours = EXCLUDED.total_allocated_hours, remaining_hours = EXCLUDED.remaining_hours, completed_hours = EXCLUDED.completed_hours, total_tasks = EXCLUDED.total_tasks, completed_tasks = EXCLUDED.completed_tasks, remaining_tasks = EXCLUDED.remaining_tasks, blocked_tasks = EXCLUDED.blocked_tasks; v_count := v_count + 1; END LOOP; -- Refrescar vista materializada PERFORM refresh_burndown_report(); RETURN v_count; END; $$ LANGUAGE plpgsql; -- Programar job diario con pg_cron (si está disponible) -- SELECT cron.schedule('burndown-snapshots', '0 1 * * *', 'SELECT create_daily_burndown_snapshots()'); ``` ### 2.3 Interfaces de Dominio #### 2.3.1 Value Objects ```typescript // src/modules/projects/domain/value-objects/burndown-metrics.vo.ts export interface BurndownMetrics { totalAllocatedHours: number; remainingHours: number; completedHours: number; totalTasks: number; completedTasks: number; blockedTasks: number; completionPercentage: number; } export interface BurndownDataPoint { date: Date; metrics: BurndownMetrics; idealRemainingHours: number; varianceHours: number; velocity: number; } export interface BurndownChartData { projectId: string; projectName: string; startDate: Date; endDate: Date; dataPoints: BurndownDataPoint[]; summary: BurndownSummary; } export interface BurndownSummary { currentVelocity: number; // Horas por día (promedio últimos 7 días) projectedEndDate: Date | null; // Fecha estimada de finalización isOnTrack: boolean; // Si va según lo planificado daysRemaining: number; // Días hasta fecha fin hoursRemaining: number; // Horas por completar percentComplete: number; // % completado } ``` #### 2.3.2 Domain Service ```typescript // src/modules/projects/domain/services/burndown.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class BurndownService { constructor( private readonly projectRepository: ProjectRepository, private readonly snapshotRepository: BurndownSnapshotRepository ) {} /** * Obtener datos de burndown para un proyecto */ async getBurndownChart( projectId: string, options?: BurndownOptions ): Promise { const project = await this.projectRepository.findById(projectId); if (!project) { throw new NotFoundException(`Project ${projectId} not found`); } const startDate = options?.startDate || project.dateStart; const endDate = options?.endDate || project.dateEnd || new Date(); const snapshots = await this.snapshotRepository.findByProjectAndDateRange( projectId, startDate, endDate ); const dataPoints = this.buildDataPoints(snapshots, startDate, endDate); const summary = this.calculateSummary(dataPoints, endDate); return { projectId, projectName: project.name, startDate, endDate, dataPoints, summary }; } /** * Construir puntos de datos con interpolación */ private buildDataPoints( snapshots: BurndownSnapshot[], startDate: Date, endDate: Date ): BurndownDataPoint[] { const totalDays = this.daysBetween(startDate, endDate); const initialHours = snapshots[0]?.totalAllocatedHours || 0; const points: BurndownDataPoint[] = []; let lastSnapshot: BurndownSnapshot | null = null; for (let i = 0; i <= totalDays; i++) { const currentDate = new Date(startDate); currentDate.setDate(currentDate.getDate() + i); const snapshot = snapshots.find(s => this.isSameDay(s.snapshotDate, currentDate) ); if (snapshot) { lastSnapshot = snapshot; } const metrics = snapshot || lastSnapshot || this.emptyMetrics(); const idealRemaining = this.calculateIdealRemaining( initialHours, i, totalDays ); points.push({ date: currentDate, metrics: { totalAllocatedHours: metrics.totalAllocatedHours, remainingHours: metrics.remainingHours, completedHours: metrics.completedHours, totalTasks: metrics.totalTasks, completedTasks: metrics.completedTasks, blockedTasks: metrics.blockedTasks, completionPercentage: metrics.totalAllocatedHours > 0 ? (metrics.completedHours / metrics.totalAllocatedHours) * 100 : 0 }, idealRemainingHours: idealRemaining, varianceHours: metrics.remainingHours - idealRemaining, velocity: this.calculateVelocity(points, metrics) }); } return points; } /** * Calcular línea ideal de burndown */ private calculateIdealRemaining( initialHours: number, elapsedDays: number, totalDays: number ): number { if (totalDays === 0) return 0; return Math.max(0, initialHours * (1 - elapsedDays / totalDays)); } /** * Calcular velocidad (promedio móvil 7 días) */ private calculateVelocity( previousPoints: BurndownDataPoint[], currentMetrics: BurndownMetrics ): number { const recentPoints = previousPoints.slice(-6); if (recentPoints.length === 0) return 0; const totalCompleted = recentPoints.reduce( (sum, p) => sum + p.metrics.completedHours, currentMetrics.completedHours ); return totalCompleted / (recentPoints.length + 1); } /** * Calcular resumen del burndown */ private calculateSummary( dataPoints: BurndownDataPoint[], plannedEndDate: Date ): BurndownSummary { const latestPoint = dataPoints[dataPoints.length - 1]; const today = new Date(); // Velocidad actual (promedio últimos 7 días con datos) const recentVelocities = dataPoints .slice(-7) .filter(p => p.velocity > 0) .map(p => p.velocity); const currentVelocity = recentVelocities.length > 0 ? recentVelocities.reduce((a, b) => a + b, 0) / recentVelocities.length : 0; // Proyección de fecha de finalización let projectedEndDate: Date | null = null; if (currentVelocity > 0 && latestPoint.metrics.remainingHours > 0) { const daysToComplete = Math.ceil( latestPoint.metrics.remainingHours / currentVelocity ); projectedEndDate = new Date(today); projectedEndDate.setDate(projectedEndDate.getDate() + daysToComplete); } // ¿Va según lo planificado? const isOnTrack = latestPoint.varianceHours <= 0; return { currentVelocity, projectedEndDate, isOnTrack, daysRemaining: this.daysBetween(today, plannedEndDate), hoursRemaining: latestPoint.metrics.remainingHours, percentComplete: latestPoint.metrics.completionPercentage }; } /** * Crear snapshot manual (para testing o correcciones) */ async createSnapshot(projectId: string, date?: Date): Promise { const snapshotDate = date || new Date(); const project = await this.projectRepository.findById(projectId); const tasks = await this.projectRepository.getProjectTasks(projectId); const metrics = tasks.reduce((acc, task) => ({ totalAllocatedHours: acc.totalAllocatedHours + (task.allocatedHours || 0), completedHours: acc.completedHours + (['1_done', '03_approved'].includes(task.state) ? task.allocatedHours || 0 : 0), remainingHours: acc.remainingHours + (!['1_done', '03_approved', '1_canceled'].includes(task.state) ? task.allocatedHours || 0 : 0), totalTasks: acc.totalTasks + 1, completedTasks: acc.completedTasks + (['1_done', '03_approved'].includes(task.state) ? 1 : 0), blockedTasks: acc.blockedTasks + (task.state === '04_waiting_normal' ? 1 : 0) }), { totalAllocatedHours: 0, completedHours: 0, remainingHours: 0, totalTasks: 0, completedTasks: 0, blockedTasks: 0 }); return this.snapshotRepository.upsert({ projectId, snapshotDate, ...metrics, remainingTasks: metrics.totalTasks - metrics.completedTasks }); } private daysBetween(date1: Date, date2: Date): number { const diffTime = Math.abs(date2.getTime() - date1.getTime()); return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); } private isSameDay(date1: Date, date2: Date): boolean { return date1.toISOString().split('T')[0] === date2.toISOString().split('T')[0]; } private emptyMetrics(): BurndownMetrics { return { totalAllocatedHours: 0, remainingHours: 0, completedHours: 0, totalTasks: 0, completedTasks: 0, blockedTasks: 0, completionPercentage: 0 }; } } interface BurndownOptions { startDate?: Date; endDate?: Date; includeMilestones?: boolean; } ``` ### 2.4 API REST ```typescript // src/modules/projects/interfaces/http/burndown.controller.ts @Controller('projects/:projectId/burndown') @ApiTags('Project Burndown') export class BurndownController { constructor( private readonly burndownService: BurndownService ) {} @Get() @ApiOperation({ summary: 'Get burndown chart data' }) @ApiQuery({ name: 'startDate', required: false }) @ApiQuery({ name: 'endDate', required: false }) async getBurndownChart( @Param('projectId') projectId: string, @Query('startDate') startDate?: string, @Query('endDate') endDate?: string ): Promise { return this.burndownService.getBurndownChart(projectId, { startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined }); } @Get('summary') @ApiOperation({ summary: 'Get burndown summary' }) async getBurndownSummary( @Param('projectId') projectId: string ): Promise { const chart = await this.burndownService.getBurndownChart(projectId); return chart.summary; } @Post('snapshot') @ApiOperation({ summary: 'Create manual snapshot' }) async createSnapshot( @Param('projectId') projectId: string, @Body() dto: CreateSnapshotDto ): Promise { return this.burndownService.createSnapshot( projectId, dto.date ? new Date(dto.date) : undefined ); } @Get('export') @ApiOperation({ summary: 'Export burndown data as CSV' }) @Header('Content-Type', 'text/csv') @Header('Content-Disposition', 'attachment; filename=burndown.csv') async exportBurndown( @Param('projectId') projectId: string, @Query('startDate') startDate?: string, @Query('endDate') endDate?: string ): Promise { const chart = await this.burndownService.getBurndownChart(projectId, { startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined }); return this.convertToCSV(chart); } private convertToCSV(chart: BurndownChartData): string { const headers = [ 'Date', 'Total Hours', 'Remaining Hours', 'Completed Hours', 'Ideal Remaining', 'Variance', 'Total Tasks', 'Completed Tasks', 'Blocked Tasks', 'Completion %' ]; const rows = chart.dataPoints.map(point => [ point.date.toISOString().split('T')[0], point.metrics.totalAllocatedHours, point.metrics.remainingHours, point.metrics.completedHours, point.idealRemainingHours.toFixed(2), point.varianceHours.toFixed(2), point.metrics.totalTasks, point.metrics.completedTasks, point.metrics.blockedTasks, point.metrics.completionPercentage.toFixed(2) ]); return [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); } } // DTOs class BurndownChartDataDto { projectId: string; projectName: string; startDate: string; endDate: string; dataPoints: BurndownDataPointDto[]; summary: BurndownSummaryDto; } class BurndownDataPointDto { date: string; totalAllocatedHours: number; remainingHours: number; completedHours: number; idealRemainingHours: number; varianceHours: number; totalTasks: number; completedTasks: number; blockedTasks: number; completionPercentage: number; velocity: number; } class BurndownSummaryDto { currentVelocity: number; projectedEndDate: string | null; isOnTrack: boolean; daysRemaining: number; hoursRemaining: number; percentComplete: number; } class CreateSnapshotDto { @IsOptional() @IsDateString() date?: string; } ``` --- ## Parte 3: Componentes Frontend ### 3.1 Componente de Dependencias ```typescript // Frontend React - Task Dependencies Component interface TaskDependenciesProps { taskId: string; projectId: string; onDependencyChange?: () => void; } export const TaskDependencies: React.FC = ({ taskId, projectId, onDependencyChange }) => { const { data: dependencies, refetch } = useQuery( ['task-dependencies', taskId], () => api.getTaskDependencies(projectId, taskId) ); const { data: availableTasks } = useQuery( ['project-tasks', projectId], () => api.getProjectTasks(projectId) ); const addDependencyMutation = useMutation( (dependsOnTaskId: string) => api.addTaskDependency(projectId, taskId, dependsOnTaskId), { onSuccess: () => { refetch(); onDependencyChange?.(); } } ); const removeDependencyMutation = useMutation( (dependsOnTaskId: string) => api.removeTaskDependency(projectId, taskId, dependsOnTaskId), { onSuccess: () => { refetch(); onDependencyChange?.(); } } ); const eligibleTasks = availableTasks?.filter(t => t.id !== taskId && !dependencies?.some(d => d.dependsOnTaskId === t.id) ); return ( Dependencies {dependencies?.length === 0 ? (

No dependencies defined

) : (
    {dependencies?.map(dep => (
  • {dep.isBlocked ? ( ) : ( )} {dep.dependsOnTaskName} {dep.dependsOnTaskState}
  • ))}
)}