# 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 '@core/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<TaskDependencyProps> {

  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<TaskDependencyProps, 'id' | 'blockStatus' | 'createdAt' | 'updatedAt'>): 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<TaskProps> {

  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<DependencyValidationResult> {
    // 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<string[]> {
    const visited = new Set<string>();
    const path: string[] = [];

    const dfs = async (currentId: string): Promise<boolean> => {
      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<TaskDependency> {
    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<void> {
    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<void> {
    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<DependencyTreeNode> {
    return this.buildDependencyTree(taskId, new Set());
  }

  private async buildDependencyTree(
    taskId: string,
    visited: Set<string>
  ): Promise<DependencyTreeNode> {
    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<TaskDependencyDto[]> {
    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<TaskDependencyDto> {
    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<void> {
    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<TaskDependencyDto> {
    return this.dependencyService.overrideDependency(taskId, dependsOnTaskId, user.id);
  }

  @Get('tree')
  @ApiOperation({ summary: 'Get dependency tree' })
  async getDependencyTree(
    @Param('taskId') taskId: string
  ): Promise<DependencyTreeDto> {
    return this.dependencyService.getDependencyTree(taskId);
  }

  @Post('validate')
  @ApiOperation({ summary: 'Validate potential dependency' })
  async validateDependency(
    @Param('taskId') taskId: string,
    @Body() dto: AddDependencyDto
  ): Promise<DependencyValidationResult> {
    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<BurndownChartData> {
    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<BurndownSnapshot> {
    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<BurndownChartDataDto> {
    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<BurndownSummaryDto> {
    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<BurndownSnapshotDto> {
    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<string> {
    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<TaskDependenciesProps> = ({
  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 (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          <Link2 className="h-5 w-5" />
          Dependencies
        </CardTitle>
      </CardHeader>
      <CardContent>
        {dependencies?.length === 0 ? (
          <p className="text-muted-foreground text-sm">
            No dependencies defined
          </p>
        ) : (
          <ul className="space-y-2">
            {dependencies?.map(dep => (
              <li
                key={dep.id}
                className="flex items-center justify-between p-2 rounded border"
              >
                <div className="flex items-center gap-2">
                  {dep.isBlocked ? (
                    <Lock className="h-4 w-4 text-amber-500" />
                  ) : (
                    <Unlock className="h-4 w-4 text-green-500" />
                  )}
                  <span>{dep.dependsOnTaskName}</span>
                  <Badge variant={getStateVariant(dep.dependsOnTaskState)}>
                    {dep.dependsOnTaskState}
                  </Badge>
                </div>
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => removeDependencyMutation.mutate(dep.dependsOnTaskId)}
                >
                  <X className="h-4 w-4" />
                </Button>
              </li>
            ))}
          </ul>
        )}

        <div className="mt-4">
          <Select
            placeholder="Add dependency..."
            options={eligibleTasks?.map(t => ({
              value: t.id,
              label: t.name
            }))}
            onChange={(value) => addDependencyMutation.mutate(value)}
          />
        </div>
      </CardContent>
    </Card>
  );
};
```

### 3.2 Componente de Burndown Chart

```typescript
// Frontend React - Burndown Chart Component

interface BurndownChartProps {
  projectId: string;
  startDate?: Date;
  endDate?: Date;
}

export const BurndownChart: React.FC<BurndownChartProps> = ({
  projectId,
  startDate,
  endDate
}) => {
  const { data: burndownData, isLoading } = useQuery(
    ['burndown', projectId, startDate, endDate],
    () => api.getBurndownChart(projectId, { startDate, endDate })
  );

  if (isLoading) return <Skeleton className="h-96 w-full" />;
  if (!burndownData) return null;

  const chartData = burndownData.dataPoints.map(point => ({
    date: new Date(point.date).toLocaleDateString(),
    remaining: point.remainingHours,
    ideal: point.idealRemainingHours,
    completed: point.completedHours
  }));

  return (
    <Card>
      <CardHeader>
        <div className="flex items-center justify-between">
          <CardTitle className="flex items-center gap-2">
            <TrendingDown className="h-5 w-5" />
            Burndown Chart
          </CardTitle>
          <BurndownSummaryBadge summary={burndownData.summary} />
        </div>
      </CardHeader>
      <CardContent>
        <ResponsiveContainer width="100%" height={400}>
          <LineChart data={chartData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis
              dataKey="date"
              tick={{ fontSize: 12 }}
              interval="preserveStartEnd"
            />
            <YAxis
              label={{
                value: 'Hours',
                angle: -90,
                position: 'insideLeft'
              }}
            />
            <Tooltip />
            <Legend />

            {/* Línea ideal */}
            <Line
              type="linear"
              dataKey="ideal"
              name="Ideal Burndown"
              stroke="#94a3b8"
              strokeDasharray="5 5"
              dot={false}
            />

            {/* Línea real de horas restantes */}
            <Line
              type="monotone"
              dataKey="remaining"
              name="Remaining Hours"
              stroke="#3b82f6"
              strokeWidth={2}
              dot={{ r: 3 }}
              activeDot={{ r: 5 }}
            />

            {/* Área de completado */}
            <Area
              type="monotone"
              dataKey="completed"
              name="Completed Hours"
              fill="#22c55e"
              fillOpacity={0.3}
              stroke="#22c55e"
            />
          </LineChart>
        </ResponsiveContainer>

        <div className="mt-4 grid grid-cols-4 gap-4">
          <MetricCard
            label="Velocity"
            value={`${burndownData.summary.currentVelocity.toFixed(1)} hrs/day`}
            icon={<Zap className="h-4 w-4" />}
          />
          <MetricCard
            label="Remaining"
            value={`${burndownData.summary.hoursRemaining.toFixed(0)} hrs`}
            icon={<Clock className="h-4 w-4" />}
          />
          <MetricCard
            label="Complete"
            value={`${burndownData.summary.percentComplete.toFixed(0)}%`}
            icon={<CheckCircle className="h-4 w-4" />}
          />
          <MetricCard
            label="Projected End"
            value={burndownData.summary.projectedEndDate
              ? new Date(burndownData.summary.projectedEndDate).toLocaleDateString()
              : 'N/A'}
            icon={<Calendar className="h-4 w-4" />}
            variant={burndownData.summary.isOnTrack ? 'success' : 'warning'}
          />
        </div>
      </CardContent>
    </Card>
  );
};

const BurndownSummaryBadge: React.FC<{ summary: BurndownSummary }> = ({ summary }) => (
  <Badge variant={summary.isOnTrack ? 'success' : 'warning'}>
    {summary.isOnTrack ? 'On Track' : 'Behind Schedule'}
  </Badge>
);
```

---

## Parte 4: Migraciones

### 4.1 Migración Principal

```sql
-- migrations/YYYYMMDD_add_task_dependencies_burndown.sql

-- Parte 1: Dependencias entre Tareas

-- Tipos enumerados
DO $$ BEGIN
    CREATE TYPE task_dependency_type AS ENUM (
        'finish_to_start',
        'start_to_start',
        'finish_to_finish',
        'start_to_finish'
    );
EXCEPTION
    WHEN duplicate_object THEN null;
END $$;

DO $$ BEGIN
    CREATE TYPE dependency_block_status AS ENUM (
        'blocked',
        'ready',
        'overridden'
    );
EXCEPTION
    WHEN duplicate_object THEN null;
END $$;

-- Tabla de dependencias
CREATE TABLE IF NOT EXISTS project_task_dependencies (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id UUID NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE,
    depends_on_task_id UUID NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE,
    dependency_type task_dependency_type NOT NULL DEFAULT 'finish_to_start',
    block_status dependency_block_status NOT NULL DEFAULT 'blocked',
    lag_days INTEGER DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by UUID NOT NULL,
    CONSTRAINT chk_no_self_dependency CHECK (task_id != depends_on_task_id),
    CONSTRAINT uq_task_dependency UNIQUE (task_id, depends_on_task_id)
);

CREATE INDEX IF NOT EXISTS idx_task_dependencies_task ON project_task_dependencies(task_id);
CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on ON project_task_dependencies(depends_on_task_id);
CREATE INDEX IF NOT EXISTS idx_task_dependencies_status ON project_task_dependencies(block_status);

-- Parte 2: Burndown Charts

CREATE TABLE IF NOT EXISTS project_burndown_snapshots (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id UUID NOT NULL REFERENCES projects(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,
    remaining_tasks INTEGER NOT NULL DEFAULT 0,
    blocked_tasks INTEGER NOT NULL DEFAULT 0,
    total_story_points DECIMAL(6,2) DEFAULT 0,
    completed_story_points DECIMAL(6,2) DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_project_snapshot_date UNIQUE (project_id, snapshot_date)
);

CREATE INDEX IF NOT EXISTS idx_burndown_project_date ON project_burndown_snapshots(project_id, snapshot_date);
CREATE INDEX IF NOT EXISTS idx_burndown_date ON project_burndown_snapshots(snapshot_date);

-- Agregar estado waiting a project_tasks si no existe
DO $$
BEGIN
    ALTER TYPE task_state ADD VALUE IF NOT EXISTS '04_waiting_normal' BEFORE '1_done';
EXCEPTION
    WHEN duplicate_object THEN null;
END $$;

-- Agregar campo allocated_hours a project_tasks si no existe
ALTER TABLE project_tasks
    ADD COLUMN IF NOT EXISTS allocated_hours DECIMAL(10,2) DEFAULT 0;

COMMENT ON TABLE project_task_dependencies IS 'Relaciones de dependencia entre tareas (Finish-to-Start por defecto)';
COMMENT ON TABLE project_burndown_snapshots IS 'Snapshots diarios para gráficos de burndown';
```

---

## Parte 5: Testing

### 5.1 Tests de Dependencias

```typescript
// tests/task-dependencies.spec.ts

describe('TaskDependencyService', () => {

  describe('validateDependency', () => {
    it('should reject self-dependency', async () => {
      const result = await service.validateDependency('task-1', 'task-1');
      expect(result.isValid).toBe(false);
    });

    it('should detect simple cycle', async () => {
      // A -> B -> A (cycle)
      await service.addDependency('task-a', 'task-b', 'user-1');

      const result = await service.validateDependency('task-b', 'task-a');
      expect(result.isValid).toBe(false);
      expect(result.hasCycle).toBe(true);
    });

    it('should detect complex cycle', async () => {
      // A -> B -> C -> A (cycle)
      await service.addDependency('task-a', 'task-b', 'user-1');
      await service.addDependency('task-b', 'task-c', 'user-1');

      const result = await service.validateDependency('task-c', 'task-a');
      expect(result.isValid).toBe(false);
      expect(result.hasCycle).toBe(true);
      expect(result.cyclePath).toEqual(['task-c', 'task-b', 'task-a']);
    });

    it('should allow valid chain', async () => {
      // A -> B -> C (no cycle)
      await service.addDependency('task-a', 'task-b', 'user-1');

      const result = await service.validateDependency('task-b', 'task-c');
      expect(result.isValid).toBe(true);
    });
  });

  describe('addDependency', () => {
    it('should block task when dependency not complete', async () => {
      const dep = await service.addDependency('task-a', 'task-b', 'user-1');

      expect(dep.blockStatus).toBe('blocked');

      const task = await taskRepository.findById('task-a');
      expect(task.state).toBe('04_waiting_normal');
    });

    it('should not block when dependency is complete', async () => {
      // Mark task-b as done first
      await taskRepository.updateState('task-b', '1_done', 'user-1');

      const dep = await service.addDependency('task-a', 'task-b', 'user-1');

      expect(dep.blockStatus).toBe('ready');
    });
  });

  describe('propagateTaskStateChange', () => {
    it('should unblock dependent tasks when completing', async () => {
      await service.addDependency('task-a', 'task-b', 'user-1');

      // Complete task-b
      await service.propagateTaskStateChange('task-b', '1_done', 'user-1');

      const taskA = await taskRepository.findById('task-a');
      expect(taskA.state).toBe('01_in_progress');
    });

    it('should re-block when uncompleting task', async () => {
      await service.addDependency('task-a', 'task-b', 'user-1');
      await service.propagateTaskStateChange('task-b', '1_done', 'user-1');

      // Uncomplete task-b
      await service.propagateTaskStateChange('task-b', '01_in_progress', 'user-1');

      const taskA = await taskRepository.findById('task-a');
      expect(taskA.state).toBe('04_waiting_normal');
    });
  });
});
```

### 5.2 Tests de Burndown

```typescript
// tests/burndown.spec.ts

describe('BurndownService', () => {

  describe('getBurndownChart', () => {
    it('should return correct data points', async () => {
      // Setup: project with 10 days, 100 hours
      await createTestProject({
        id: 'project-1',
        startDate: new Date('2025-01-01'),
        endDate: new Date('2025-01-10')
      });

      await createSnapshots('project-1', [
        { date: '2025-01-01', remaining: 100, completed: 0 },
        { date: '2025-01-02', remaining: 90, completed: 10 },
        { date: '2025-01-03', remaining: 75, completed: 25 }
      ]);

      const chart = await service.getBurndownChart('project-1');

      expect(chart.dataPoints).toHaveLength(10);
      expect(chart.dataPoints[0].remainingHours).toBe(100);
      expect(chart.dataPoints[2].remainingHours).toBe(75);
    });

    it('should calculate ideal burndown line', async () => {
      await createTestProject({
        id: 'project-1',
        startDate: new Date('2025-01-01'),
        endDate: new Date('2025-01-11') // 10 days
      });

      await createSnapshots('project-1', [
        { date: '2025-01-01', remaining: 100, completed: 0, total: 100 }
      ]);

      const chart = await service.getBurndownChart('project-1');

      // Day 0: 100 ideal remaining
      expect(chart.dataPoints[0].idealRemainingHours).toBe(100);
      // Day 5: 50 ideal remaining
      expect(chart.dataPoints[5].idealRemainingHours).toBe(50);
      // Day 10: 0 ideal remaining
      expect(chart.dataPoints[10].idealRemainingHours).toBe(0);
    });

    it('should detect on track status', async () => {
      // Project with remaining < ideal = on track
      await createTestProject({ id: 'project-1' });
      await createSnapshots('project-1', [
        { date: '2025-01-05', remaining: 40, completed: 60, total: 100 }
        // At day 5 of 10, ideal is 50, actual is 40 -> on track
      ]);

      const chart = await service.getBurndownChart('project-1');
      expect(chart.summary.isOnTrack).toBe(true);
    });

    it('should detect behind schedule status', async () => {
      await createTestProject({ id: 'project-1' });
      await createSnapshots('project-1', [
        { date: '2025-01-05', remaining: 70, completed: 30, total: 100 }
        // At day 5 of 10, ideal is 50, actual is 70 -> behind
      ]);

      const chart = await service.getBurndownChart('project-1');
      expect(chart.summary.isOnTrack).toBe(false);
    });
  });

  describe('calculateVelocity', () => {
    it('should calculate 7-day rolling average', async () => {
      await createTestProject({ id: 'project-1' });

      // 7 days with 10 hours completed each = 10 velocity
      for (let i = 0; i < 7; i++) {
        await createSnapshots('project-1', [{
          date: `2025-01-0${i + 1}`,
          remaining: 100 - (i + 1) * 10,
          completed: (i + 1) * 10,
          total: 100
        }]);
      }

      const chart = await service.getBurndownChart('project-1');
      expect(chart.summary.currentVelocity).toBeCloseTo(10, 1);
    });
  });

  describe('createSnapshot', () => {
    it('should aggregate task metrics', async () => {
      await createTestProject({ id: 'project-1' });
      await createTestTasks('project-1', [
        { allocatedHours: 20, state: '1_done' },
        { allocatedHours: 30, state: '01_in_progress' },
        { allocatedHours: 10, state: '04_waiting_normal' }
      ]);

      const snapshot = await service.createSnapshot('project-1');

      expect(snapshot.totalAllocatedHours).toBe(60);
      expect(snapshot.completedHours).toBe(20);
      expect(snapshot.remainingHours).toBe(40);
      expect(snapshot.totalTasks).toBe(3);
      expect(snapshot.completedTasks).toBe(1);
      expect(snapshot.blockedTasks).toBe(1);
    });
  });
});
```

---

## Parte 6: Resumen de Implementación

### 6.1 Gaps Cubiertos

| Gap ID | Descripción | Estado |
|--------|-------------|--------|
| GAP-MGN-011-001 | Dependencias entre Tareas | ✅ Especificado |
| GAP-MGN-011-002 | Burndown Charts | ✅ Especificado |

### 6.2 Componentes Definidos

| Componente | Tipo | Descripción |
|------------|------|-------------|
| `project_task_dependencies` | Tabla | Relaciones Many2Many de dependencias |
| `project_burndown_snapshots` | Tabla | Snapshots diarios para burndown |
| `TaskDependency` | Entity | Entidad de dominio para dependencia |
| `TaskDependencyService` | Service | Lógica de validación y propagación |
| `BurndownService` | Service | Cálculo de métricas y proyecciones |
| `TaskDependenciesController` | API | Endpoints REST para dependencias |
| `BurndownController` | API | Endpoints REST para burndown |

### 6.3 Decisiones de Diseño

1. **Solo Finish-to-Start**: Como Odoo 18, solo implementamos F2S por simplicidad. Extensible a otros tipos.

2. **Validación de Ciclos**: Implementada con DFS y límite de profundidad para evitar loops infinitos.

3. **Snapshots Diarios**: Job programado para crear snapshots, no cálculo en tiempo real (mejor performance).

4. **Vista Materializada**: Para reportes de burndown, refrescada después de cada snapshot.

5. **Override de Bloqueos**: Permitido para casos especiales donde se necesita trabajar a pesar de dependencias.

### 6.4 Integración con Sistema Existente

- Extiende `project_tasks` con estado `04_waiting_normal`
- Agrega campo `allocated_hours` para métricas de burndown
- Compatible con milestones existentes
- Reutiliza patrón de auditoría `created_by/updated_by`

---

## Referencias

- Odoo 18 `project/models/project_task.py` - Campos depend_on_ids, dependent_ids
- Odoo 18 `project/report/project_task_burndown_chart_report.py` - Modelo SQL de reporte
- SPEC-PROYECTOS-BASE - Especificación base de proyectos y tareas
- IEEE 830 - Estándar para especificaciones de requisitos
