🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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:
-
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.
-
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_idsydependent_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
-
Solo Finish-to-Start: Como Odoo 18, solo implementamos F2S por simplicidad. Extensible a otros tipos.
-
Validación de Ciclos: Implementada con DFS y límite de profundidad para evitar loops infinitos.
-
Snapshots Diarios: Job programado para crear snapshots, no cálculo en tiempo real (mejor performance).
-
Vista Materializada: Para reportes de burndown, refrescada después de cada snapshot.
-
Override de Bloqueos: Permitido para casos especiales donde se necesita trabajar a pesar de dependencias.
6.4 Integración con Sistema Existente
- Extiende
project_taskscon estado04_waiting_normal - Agrega campo
allocated_hourspara 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