erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN.md
rckrdmrd 4c4e27d9ba feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:20 -06:00

62 KiB

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

-- 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

-- 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

-- 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

-- 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

-- 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

// 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<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

// 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

// 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

// 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

-- 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

-- 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

-- 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

// 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

// 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

// 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

// 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

// 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

-- 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

// 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

// 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