🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
968 lines
34 KiB
PL/PgSQL
968 lines
34 KiB
PL/PgSQL
-- =====================================================
|
|
-- SCHEMA: projects
|
|
-- PROPÓSITO: Gestión de proyectos, tareas, milestones
|
|
-- MÓDULOS: MGN-011 (Proyectos Genéricos)
|
|
-- FECHA: 2025-11-24
|
|
-- =====================================================
|
|
|
|
-- Crear schema
|
|
CREATE SCHEMA IF NOT EXISTS projects;
|
|
|
|
-- =====================================================
|
|
-- TYPES (ENUMs)
|
|
-- =====================================================
|
|
|
|
CREATE TYPE projects.project_status AS ENUM (
|
|
'draft',
|
|
'active',
|
|
'completed',
|
|
'cancelled',
|
|
'on_hold'
|
|
);
|
|
|
|
CREATE TYPE projects.privacy_type AS ENUM (
|
|
'public',
|
|
'private',
|
|
'followers'
|
|
);
|
|
|
|
CREATE TYPE projects.task_status AS ENUM (
|
|
'todo',
|
|
'in_progress',
|
|
'review',
|
|
'done',
|
|
'cancelled'
|
|
);
|
|
|
|
CREATE TYPE projects.task_priority AS ENUM (
|
|
'low',
|
|
'normal',
|
|
'high',
|
|
'urgent'
|
|
);
|
|
|
|
-- COR-016: Tipos de recurrencia
|
|
CREATE TYPE projects.recurrence_type AS ENUM (
|
|
'daily',
|
|
'weekly',
|
|
'monthly',
|
|
'yearly',
|
|
'custom'
|
|
);
|
|
|
|
CREATE TYPE projects.dependency_type AS ENUM (
|
|
'finish_to_start',
|
|
'start_to_start',
|
|
'finish_to_finish',
|
|
'start_to_finish'
|
|
);
|
|
|
|
CREATE TYPE projects.milestone_status AS ENUM (
|
|
'pending',
|
|
'completed'
|
|
);
|
|
|
|
-- =====================================================
|
|
-- TABLES
|
|
-- =====================================================
|
|
|
|
-- Tabla: projects (Proyectos)
|
|
CREATE TABLE projects.projects (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
|
|
-- Identificación
|
|
name VARCHAR(255) NOT NULL,
|
|
code VARCHAR(50),
|
|
description TEXT,
|
|
|
|
-- Responsables
|
|
manager_id UUID REFERENCES auth.users(id),
|
|
partner_id UUID REFERENCES core.partners(id), -- Cliente
|
|
|
|
-- Analítica (1-1)
|
|
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id),
|
|
|
|
-- Fechas
|
|
date_start DATE,
|
|
date_end DATE,
|
|
|
|
-- Estado
|
|
status projects.project_status NOT NULL DEFAULT 'draft',
|
|
privacy projects.privacy_type NOT NULL DEFAULT 'public',
|
|
|
|
-- Configuración
|
|
allow_timesheets BOOLEAN DEFAULT TRUE,
|
|
color VARCHAR(20), -- Color para UI
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMP,
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
deleted_at TIMESTAMP,
|
|
deleted_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT uq_projects_code_company UNIQUE (company_id, code),
|
|
CONSTRAINT chk_projects_dates CHECK (date_end IS NULL OR date_end >= date_start)
|
|
);
|
|
|
|
-- Tabla: project_stages (Etapas de tareas)
|
|
CREATE TABLE projects.project_stages (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
project_id UUID REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
|
|
name VARCHAR(100) NOT NULL,
|
|
sequence INTEGER NOT NULL DEFAULT 1,
|
|
is_closed BOOLEAN DEFAULT FALSE, -- Etapa final
|
|
fold BOOLEAN DEFAULT FALSE, -- Plegada en kanban
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT chk_project_stages_sequence CHECK (sequence > 0)
|
|
);
|
|
|
|
-- Tabla: tasks (Tareas)
|
|
CREATE TABLE projects.tasks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
stage_id UUID REFERENCES projects.project_stages(id),
|
|
|
|
-- Identificación
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Asignación
|
|
assigned_to UUID REFERENCES auth.users(id),
|
|
partner_id UUID REFERENCES core.partners(id),
|
|
|
|
-- Jerarquía
|
|
parent_id UUID REFERENCES projects.tasks(id),
|
|
|
|
-- Fechas
|
|
date_start DATE,
|
|
date_deadline DATE,
|
|
|
|
-- Esfuerzo
|
|
planned_hours DECIMAL(8, 2) DEFAULT 0,
|
|
actual_hours DECIMAL(8, 2) DEFAULT 0,
|
|
progress INTEGER DEFAULT 0, -- 0-100
|
|
|
|
-- Prioridad y estado
|
|
priority projects.task_priority NOT NULL DEFAULT 'normal',
|
|
status projects.task_status NOT NULL DEFAULT 'todo',
|
|
|
|
-- Milestone
|
|
milestone_id UUID REFERENCES projects.milestones(id),
|
|
|
|
-- COR-016: Recurrencia
|
|
is_recurring BOOLEAN DEFAULT FALSE,
|
|
recurrence_type projects.recurrence_type,
|
|
recurrence_interval INTEGER DEFAULT 1, -- Cada N dias/semanas/meses
|
|
recurrence_weekdays INTEGER[] DEFAULT '{}', -- 0=Lunes, 6=Domingo
|
|
recurrence_month_day INTEGER, -- Dia del mes (1-31)
|
|
recurrence_end_type VARCHAR(20) DEFAULT 'never', -- never, count, date
|
|
recurrence_count INTEGER, -- Numero de repeticiones
|
|
recurrence_end_date DATE, -- Fecha fin de recurrencia
|
|
recurrence_parent_id UUID REFERENCES projects.tasks(id), -- Tarea padre recurrente
|
|
last_recurrence_date DATE,
|
|
next_recurrence_date DATE,
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMP,
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
deleted_at TIMESTAMP,
|
|
deleted_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT chk_tasks_no_self_parent CHECK (id != parent_id),
|
|
CONSTRAINT chk_tasks_dates CHECK (date_deadline IS NULL OR date_deadline >= date_start),
|
|
CONSTRAINT chk_tasks_planned_hours CHECK (planned_hours >= 0),
|
|
CONSTRAINT chk_tasks_actual_hours CHECK (actual_hours >= 0),
|
|
CONSTRAINT chk_tasks_progress CHECK (progress >= 0 AND progress <= 100),
|
|
CONSTRAINT chk_tasks_recurrence_interval CHECK (recurrence_interval IS NULL OR recurrence_interval > 0),
|
|
CONSTRAINT chk_tasks_recurrence_end_type CHECK (recurrence_end_type IN ('never', 'count', 'date'))
|
|
);
|
|
|
|
-- Tabla: milestones (Hitos)
|
|
CREATE TABLE projects.milestones (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
target_date DATE NOT NULL,
|
|
status projects.milestone_status NOT NULL DEFAULT 'pending',
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMP,
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
completed_at TIMESTAMP,
|
|
completed_by UUID REFERENCES auth.users(id)
|
|
);
|
|
|
|
-- Tabla: task_dependencies (Dependencias entre tareas)
|
|
CREATE TABLE projects.task_dependencies (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
depends_on_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
|
|
dependency_type projects.dependency_type NOT NULL DEFAULT 'finish_to_start',
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT uq_task_dependencies UNIQUE (task_id, depends_on_id),
|
|
CONSTRAINT chk_task_dependencies_no_self CHECK (task_id != depends_on_id)
|
|
);
|
|
|
|
-- Tabla: task_tags (Etiquetas de tareas)
|
|
CREATE TABLE projects.task_tags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
name VARCHAR(100) NOT NULL,
|
|
color VARCHAR(20),
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT uq_task_tags_name_tenant UNIQUE (tenant_id, name)
|
|
);
|
|
|
|
-- Tabla: task_tag_assignments (Many-to-many)
|
|
CREATE TABLE projects.task_tag_assignments (
|
|
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
tag_id UUID NOT NULL REFERENCES projects.task_tags(id) ON DELETE CASCADE,
|
|
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (task_id, tag_id)
|
|
);
|
|
|
|
-- COR-017: Tabla para asignacion multiple de usuarios
|
|
CREATE TABLE projects.task_assignees (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Rol del usuario en la tarea
|
|
role VARCHAR(50) DEFAULT 'assignee', -- assignee, reviewer, observer
|
|
|
|
-- Control
|
|
is_primary BOOLEAN DEFAULT FALSE, -- Usuario principal
|
|
|
|
-- Auditoria
|
|
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
assigned_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT uq_task_assignees UNIQUE (task_id, user_id)
|
|
);
|
|
|
|
-- Indice para task_assignees
|
|
CREATE INDEX idx_task_assignees_task_id ON projects.task_assignees(task_id);
|
|
CREATE INDEX idx_task_assignees_user_id ON projects.task_assignees(user_id);
|
|
CREATE INDEX idx_task_assignees_primary ON projects.task_assignees(task_id, is_primary) WHERE is_primary = TRUE;
|
|
|
|
-- RLS para task_assignees
|
|
ALTER TABLE projects.task_assignees ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Politica basada en el task (hereda de la tarea)
|
|
CREATE POLICY task_assignees_via_task ON projects.task_assignees
|
|
USING (
|
|
task_id IN (
|
|
SELECT id FROM projects.tasks
|
|
WHERE tenant_id = get_current_tenant_id()
|
|
)
|
|
);
|
|
|
|
-- Tabla: timesheets (Registro de horas)
|
|
CREATE TABLE projects.timesheets (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
|
|
task_id UUID REFERENCES projects.tasks(id) ON DELETE SET NULL,
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id),
|
|
|
|
employee_id UUID, -- FK a hr.employees (se crea después)
|
|
user_id UUID REFERENCES auth.users(id),
|
|
|
|
-- Fecha y horas
|
|
date DATE NOT NULL,
|
|
hours DECIMAL(8, 2) NOT NULL,
|
|
|
|
-- Descripción
|
|
description TEXT,
|
|
|
|
-- Analítica
|
|
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica
|
|
analytic_line_id UUID REFERENCES analytics.analytic_lines(id), -- Línea analítica generada
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMP,
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT chk_timesheets_hours CHECK (hours > 0)
|
|
);
|
|
|
|
-- Tabla: task_checklists (Checklists dentro de tareas)
|
|
CREATE TABLE projects.task_checklists (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
|
|
item_name VARCHAR(255) NOT NULL,
|
|
is_completed BOOLEAN DEFAULT FALSE,
|
|
sequence INTEGER DEFAULT 1,
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
completed_at TIMESTAMP,
|
|
completed_by UUID REFERENCES auth.users(id)
|
|
);
|
|
|
|
-- Tabla: project_templates (Plantillas de proyectos)
|
|
CREATE TABLE projects.project_templates (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Template data (JSON con estructura de proyecto, tareas, etc.)
|
|
template_data JSONB DEFAULT '{}',
|
|
|
|
-- Control
|
|
active BOOLEAN DEFAULT TRUE,
|
|
|
|
-- Auditoría
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMP,
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT uq_project_templates_name_tenant UNIQUE (tenant_id, name)
|
|
);
|
|
|
|
-- =====================================================
|
|
-- INDICES
|
|
-- =====================================================
|
|
|
|
-- Projects
|
|
CREATE INDEX idx_projects_tenant_id ON projects.projects(tenant_id);
|
|
CREATE INDEX idx_projects_company_id ON projects.projects(company_id);
|
|
CREATE INDEX idx_projects_manager_id ON projects.projects(manager_id);
|
|
CREATE INDEX idx_projects_partner_id ON projects.projects(partner_id);
|
|
CREATE INDEX idx_projects_analytic_account_id ON projects.projects(analytic_account_id);
|
|
CREATE INDEX idx_projects_status ON projects.projects(status);
|
|
|
|
-- Project Stages
|
|
CREATE INDEX idx_project_stages_tenant_id ON projects.project_stages(tenant_id);
|
|
CREATE INDEX idx_project_stages_project_id ON projects.project_stages(project_id);
|
|
CREATE INDEX idx_project_stages_sequence ON projects.project_stages(sequence);
|
|
|
|
-- Tasks
|
|
CREATE INDEX idx_tasks_tenant_id ON projects.tasks(tenant_id);
|
|
CREATE INDEX idx_tasks_project_id ON projects.tasks(project_id);
|
|
CREATE INDEX idx_tasks_stage_id ON projects.tasks(stage_id);
|
|
CREATE INDEX idx_tasks_assigned_to ON projects.tasks(assigned_to);
|
|
CREATE INDEX idx_tasks_parent_id ON projects.tasks(parent_id);
|
|
CREATE INDEX idx_tasks_milestone_id ON projects.tasks(milestone_id);
|
|
CREATE INDEX idx_tasks_status ON projects.tasks(status);
|
|
CREATE INDEX idx_tasks_priority ON projects.tasks(priority);
|
|
CREATE INDEX idx_tasks_date_deadline ON projects.tasks(date_deadline);
|
|
CREATE INDEX idx_tasks_is_recurring ON projects.tasks(is_recurring) WHERE is_recurring = TRUE; -- COR-016
|
|
CREATE INDEX idx_tasks_recurrence_parent ON projects.tasks(recurrence_parent_id); -- COR-016
|
|
CREATE INDEX idx_tasks_next_recurrence ON projects.tasks(next_recurrence_date); -- COR-016
|
|
|
|
-- Milestones
|
|
CREATE INDEX idx_milestones_tenant_id ON projects.milestones(tenant_id);
|
|
CREATE INDEX idx_milestones_project_id ON projects.milestones(project_id);
|
|
CREATE INDEX idx_milestones_status ON projects.milestones(status);
|
|
CREATE INDEX idx_milestones_target_date ON projects.milestones(target_date);
|
|
|
|
-- Task Dependencies
|
|
CREATE INDEX idx_task_dependencies_task_id ON projects.task_dependencies(task_id);
|
|
CREATE INDEX idx_task_dependencies_depends_on_id ON projects.task_dependencies(depends_on_id);
|
|
|
|
-- Timesheets
|
|
CREATE INDEX idx_timesheets_tenant_id ON projects.timesheets(tenant_id);
|
|
CREATE INDEX idx_timesheets_company_id ON projects.timesheets(company_id);
|
|
CREATE INDEX idx_timesheets_task_id ON projects.timesheets(task_id);
|
|
CREATE INDEX idx_timesheets_project_id ON projects.timesheets(project_id);
|
|
CREATE INDEX idx_timesheets_employee_id ON projects.timesheets(employee_id);
|
|
CREATE INDEX idx_timesheets_date ON projects.timesheets(date);
|
|
CREATE INDEX idx_timesheets_analytic_account_id ON projects.timesheets(analytic_account_id) WHERE analytic_account_id IS NOT NULL;
|
|
|
|
-- Task Checklists
|
|
CREATE INDEX idx_task_checklists_task_id ON projects.task_checklists(task_id);
|
|
|
|
-- Project Templates
|
|
CREATE INDEX idx_project_templates_tenant_id ON projects.project_templates(tenant_id);
|
|
CREATE INDEX idx_project_templates_active ON projects.project_templates(active) WHERE active = TRUE;
|
|
|
|
-- =====================================================
|
|
-- FUNCTIONS
|
|
-- =====================================================
|
|
|
|
-- Función: update_task_actual_hours
|
|
CREATE OR REPLACE FUNCTION projects.update_task_actual_hours()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
UPDATE projects.tasks
|
|
SET actual_hours = (
|
|
SELECT COALESCE(SUM(hours), 0)
|
|
FROM projects.timesheets
|
|
WHERE task_id = OLD.task_id
|
|
)
|
|
WHERE id = OLD.task_id;
|
|
ELSE
|
|
UPDATE projects.tasks
|
|
SET actual_hours = (
|
|
SELECT COALESCE(SUM(hours), 0)
|
|
FROM projects.timesheets
|
|
WHERE task_id = NEW.task_id
|
|
)
|
|
WHERE id = NEW.task_id;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION projects.update_task_actual_hours IS 'Actualiza las horas reales de una tarea al cambiar timesheets';
|
|
|
|
-- Función: check_task_dependencies
|
|
CREATE OR REPLACE FUNCTION projects.check_task_dependencies(p_task_id UUID)
|
|
RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
v_unfinished_count INTEGER;
|
|
BEGIN
|
|
SELECT COUNT(*)
|
|
INTO v_unfinished_count
|
|
FROM projects.task_dependencies td
|
|
JOIN projects.tasks t ON td.depends_on_id = t.id
|
|
WHERE td.task_id = p_task_id
|
|
AND t.status != 'done';
|
|
|
|
RETURN v_unfinished_count = 0;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
COMMENT ON FUNCTION projects.check_task_dependencies IS 'Verifica si todas las dependencias de una tarea están completadas';
|
|
|
|
-- Función: prevent_circular_dependencies
|
|
CREATE OR REPLACE FUNCTION projects.prevent_circular_dependencies()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
-- Verificar si crear esta dependencia crea un ciclo
|
|
IF EXISTS (
|
|
WITH RECURSIVE dep_chain AS (
|
|
SELECT task_id, depends_on_id
|
|
FROM projects.task_dependencies
|
|
WHERE task_id = NEW.depends_on_id
|
|
|
|
UNION ALL
|
|
|
|
SELECT td.task_id, td.depends_on_id
|
|
FROM projects.task_dependencies td
|
|
JOIN dep_chain dc ON td.task_id = dc.depends_on_id
|
|
)
|
|
SELECT 1 FROM dep_chain WHERE depends_on_id = NEW.task_id
|
|
) THEN
|
|
RAISE EXCEPTION 'Cannot create circular dependency';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION projects.prevent_circular_dependencies IS 'Previene la creación de dependencias circulares entre tareas';
|
|
|
|
-- COR-016: Funcion para crear tarea recurrente
|
|
CREATE OR REPLACE FUNCTION projects.create_next_recurring_task(p_task_id UUID)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_task RECORD;
|
|
v_new_task_id UUID;
|
|
v_next_date DATE;
|
|
v_occurrence_count INTEGER;
|
|
BEGIN
|
|
-- Obtener tarea original
|
|
SELECT * INTO v_task FROM projects.tasks WHERE id = p_task_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Task % not found', p_task_id;
|
|
END IF;
|
|
|
|
IF NOT v_task.is_recurring THEN
|
|
RAISE EXCEPTION 'Task % is not recurring', p_task_id;
|
|
END IF;
|
|
|
|
-- Calcular siguiente fecha
|
|
v_next_date := COALESCE(v_task.next_recurrence_date, v_task.date_deadline, CURRENT_DATE);
|
|
|
|
CASE v_task.recurrence_type
|
|
WHEN 'daily' THEN
|
|
v_next_date := v_next_date + (v_task.recurrence_interval || ' days')::INTERVAL;
|
|
WHEN 'weekly' THEN
|
|
v_next_date := v_next_date + (v_task.recurrence_interval * 7 || ' days')::INTERVAL;
|
|
WHEN 'monthly' THEN
|
|
v_next_date := v_next_date + (v_task.recurrence_interval || ' months')::INTERVAL;
|
|
WHEN 'yearly' THEN
|
|
v_next_date := v_next_date + (v_task.recurrence_interval || ' years')::INTERVAL;
|
|
ELSE
|
|
v_next_date := v_next_date + (v_task.recurrence_interval || ' days')::INTERVAL;
|
|
END CASE;
|
|
|
|
-- Verificar si debe crear nueva tarea
|
|
IF v_task.recurrence_end_type = 'date' AND v_next_date > v_task.recurrence_end_date THEN
|
|
RETURN NULL; -- Fin de recurrencia por fecha
|
|
END IF;
|
|
|
|
IF v_task.recurrence_end_type = 'count' THEN
|
|
SELECT COUNT(*) INTO v_occurrence_count
|
|
FROM projects.tasks
|
|
WHERE recurrence_parent_id = COALESCE(v_task.recurrence_parent_id, v_task.id);
|
|
|
|
IF v_occurrence_count >= v_task.recurrence_count THEN
|
|
RETURN NULL; -- Fin de recurrencia por conteo
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Crear nueva tarea
|
|
INSERT INTO projects.tasks (
|
|
tenant_id, project_id, stage_id, name, description,
|
|
assigned_to, partner_id, parent_id,
|
|
date_start, date_deadline, planned_hours,
|
|
priority, status, milestone_id,
|
|
is_recurring, recurrence_type, recurrence_interval,
|
|
recurrence_weekdays, recurrence_month_day,
|
|
recurrence_end_type, recurrence_count, recurrence_end_date,
|
|
recurrence_parent_id, created_by
|
|
) VALUES (
|
|
v_task.tenant_id, v_task.project_id, v_task.stage_id, v_task.name, v_task.description,
|
|
v_task.assigned_to, v_task.partner_id, v_task.parent_id,
|
|
v_next_date, v_next_date, v_task.planned_hours,
|
|
v_task.priority, 'todo', v_task.milestone_id,
|
|
v_task.is_recurring, v_task.recurrence_type, v_task.recurrence_interval,
|
|
v_task.recurrence_weekdays, v_task.recurrence_month_day,
|
|
v_task.recurrence_end_type, v_task.recurrence_count, v_task.recurrence_end_date,
|
|
COALESCE(v_task.recurrence_parent_id, v_task.id), v_task.created_by
|
|
) RETURNING id INTO v_new_task_id;
|
|
|
|
-- Actualizar tarea original
|
|
UPDATE projects.tasks
|
|
SET last_recurrence_date = CURRENT_DATE,
|
|
next_recurrence_date = v_next_date
|
|
WHERE id = p_task_id;
|
|
|
|
-- Copiar asignaciones multiples (COR-017)
|
|
INSERT INTO projects.task_assignees (task_id, user_id, role, is_primary, assigned_by)
|
|
SELECT v_new_task_id, user_id, role, is_primary, assigned_by
|
|
FROM projects.task_assignees
|
|
WHERE task_id = p_task_id;
|
|
|
|
RETURN v_new_task_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION projects.create_next_recurring_task IS
|
|
'COR-016: Crea la siguiente ocurrencia de una tarea recurrente';
|
|
|
|
-- =====================================================
|
|
-- TRIGGERS
|
|
-- =====================================================
|
|
|
|
CREATE TRIGGER trg_projects_updated_at
|
|
BEFORE UPDATE ON projects.projects
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
|
|
CREATE TRIGGER trg_tasks_updated_at
|
|
BEFORE UPDATE ON projects.tasks
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
|
|
CREATE TRIGGER trg_milestones_updated_at
|
|
BEFORE UPDATE ON projects.milestones
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
|
|
CREATE TRIGGER trg_timesheets_updated_at
|
|
BEFORE UPDATE ON projects.timesheets
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
|
|
CREATE TRIGGER trg_project_templates_updated_at
|
|
BEFORE UPDATE ON projects.project_templates
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
|
|
-- Trigger: Actualizar horas reales de tarea al cambiar timesheet
|
|
CREATE TRIGGER trg_timesheets_update_task_hours
|
|
AFTER INSERT OR UPDATE OR DELETE ON projects.timesheets
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION projects.update_task_actual_hours();
|
|
|
|
-- Trigger: Prevenir dependencias circulares
|
|
CREATE TRIGGER trg_task_dependencies_prevent_circular
|
|
BEFORE INSERT ON projects.task_dependencies
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION projects.prevent_circular_dependencies();
|
|
|
|
-- =====================================================
|
|
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
-- =====================================================
|
|
|
|
-- Trigger: Tracking automático para proyectos
|
|
CREATE TRIGGER track_project_changes
|
|
AFTER INSERT OR UPDATE OR DELETE ON projects.projects
|
|
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
|
|
COMMENT ON TRIGGER track_project_changes ON projects.projects IS
|
|
'Registra automáticamente cambios en proyectos (estado, nombre, responsable, fechas)';
|
|
|
|
-- =====================================================
|
|
-- ROW LEVEL SECURITY (RLS)
|
|
-- =====================================================
|
|
|
|
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.project_stages ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.tasks ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.milestones ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.task_tags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.timesheets ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE projects.project_templates ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY tenant_isolation_projects ON projects.projects
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_project_stages ON projects.project_stages
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_tasks ON projects.tasks
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_milestones ON projects.milestones
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_task_tags ON projects.task_tags
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_timesheets ON projects.timesheets
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
CREATE POLICY tenant_isolation_project_templates ON projects.project_templates
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
-- =====================================================
|
|
-- COMENTARIOS
|
|
-- =====================================================
|
|
|
|
COMMENT ON SCHEMA projects IS 'Schema de gestión de proyectos, tareas y timesheets';
|
|
COMMENT ON TABLE projects.projects IS 'Proyectos genéricos con tracking de tareas';
|
|
COMMENT ON TABLE projects.project_stages IS 'Etapas/columnas para tablero Kanban de tareas';
|
|
COMMENT ON TABLE projects.tasks IS 'Tareas dentro de proyectos con jerarquía y dependencias';
|
|
COMMENT ON TABLE projects.milestones IS 'Hitos importantes en proyectos';
|
|
COMMENT ON TABLE projects.task_dependencies IS 'Dependencias entre tareas (precedencia)';
|
|
COMMENT ON TABLE projects.task_tags IS 'Etiquetas para categorizar tareas';
|
|
COMMENT ON TABLE projects.timesheets IS 'Registro de horas trabajadas en tareas';
|
|
COMMENT ON TABLE projects.task_checklists IS 'Checklists dentro de tareas';
|
|
COMMENT ON TABLE projects.project_templates IS 'Plantillas de proyectos para reutilización';
|
|
COMMENT ON TABLE projects.task_assignees IS 'COR-017: Asignacion multiple de usuarios a tareas';
|
|
|
|
-- =====================================================
|
|
-- COR-032: Project Updates
|
|
-- Equivalente a project.update de Odoo
|
|
-- =====================================================
|
|
|
|
CREATE TYPE projects.update_status AS ENUM ('on_track', 'at_risk', 'off_track', 'done');
|
|
|
|
CREATE TABLE projects.project_updates (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
status projects.update_status DEFAULT 'on_track',
|
|
progress INTEGER CHECK (progress >= 0 AND progress <= 100),
|
|
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
description TEXT,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_project_updates_tenant ON projects.project_updates(tenant_id);
|
|
CREATE INDEX idx_project_updates_project ON projects.project_updates(project_id);
|
|
CREATE INDEX idx_project_updates_date ON projects.project_updates(date DESC);
|
|
|
|
-- RLS
|
|
ALTER TABLE projects.project_updates ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_project_updates ON projects.project_updates
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
COMMENT ON TABLE projects.project_updates IS 'COR-032: Project updates - Equivalent to project.update';
|
|
|
|
-- =====================================================
|
|
-- COR-056: Project Collaborators
|
|
-- Equivalente a project.collaborator de Odoo
|
|
-- =====================================================
|
|
|
|
CREATE TABLE projects.collaborators (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
partner_id UUID REFERENCES core.partners(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Permisos
|
|
can_read BOOLEAN DEFAULT TRUE,
|
|
can_write BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Auditoria
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
invited_by UUID REFERENCES auth.users(id),
|
|
|
|
CONSTRAINT chk_collaborator_partner_or_user CHECK (
|
|
(partner_id IS NOT NULL AND user_id IS NULL) OR
|
|
(partner_id IS NULL AND user_id IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_project_collaborators_project ON projects.collaborators(project_id);
|
|
CREATE INDEX idx_project_collaborators_partner ON projects.collaborators(partner_id);
|
|
CREATE INDEX idx_project_collaborators_user ON projects.collaborators(user_id);
|
|
|
|
-- RLS basado en proyecto
|
|
ALTER TABLE projects.collaborators ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY collaborators_via_project ON projects.collaborators
|
|
USING (
|
|
project_id IN (
|
|
SELECT id FROM projects.projects
|
|
WHERE tenant_id = get_current_tenant_id()
|
|
)
|
|
);
|
|
|
|
COMMENT ON TABLE projects.collaborators IS 'COR-056: Project collaborators for external access';
|
|
|
|
-- =====================================================
|
|
-- COR-057: Project Additional Fields
|
|
-- Campos adicionales para alinear con Odoo
|
|
-- =====================================================
|
|
|
|
-- Agregar campos a projects
|
|
ALTER TABLE projects.projects
|
|
ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10,
|
|
ADD COLUMN IF NOT EXISTS favorite BOOLEAN DEFAULT FALSE,
|
|
ADD COLUMN IF NOT EXISTS is_favorite BOOLEAN DEFAULT FALSE,
|
|
ADD COLUMN IF NOT EXISTS tag_ids UUID[] DEFAULT '{}',
|
|
ADD COLUMN IF NOT EXISTS last_update_status VARCHAR(20), -- on_track, at_risk, off_track
|
|
ADD COLUMN IF NOT EXISTS last_update_color INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS task_count INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS open_task_count INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS closed_task_count INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS rating_percentage DECIMAL(5,2) DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS rating_count INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS alias_name VARCHAR(100), -- Email alias
|
|
ADD COLUMN IF NOT EXISTS alias_model VARCHAR(100) DEFAULT 'project.task';
|
|
|
|
-- Agregar campos a tasks
|
|
ALTER TABLE projects.tasks
|
|
ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10,
|
|
ADD COLUMN IF NOT EXISTS color INTEGER DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS kanban_state VARCHAR(20) DEFAULT 'normal', -- normal, blocked, done
|
|
ADD COLUMN IF NOT EXISTS legend_blocked VARCHAR(255),
|
|
ADD COLUMN IF NOT EXISTS legend_done VARCHAR(255),
|
|
ADD COLUMN IF NOT EXISTS legend_normal VARCHAR(255),
|
|
ADD COLUMN IF NOT EXISTS working_hours_open DECIMAL(10,2) DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS working_hours_close DECIMAL(10,2) DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS working_days_open DECIMAL(10,2) DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS working_days_close DECIMAL(10,2) DEFAULT 0,
|
|
ADD COLUMN IF NOT EXISTS rating_ids UUID[] DEFAULT '{}',
|
|
ADD COLUMN IF NOT EXISTS email_cc VARCHAR(255),
|
|
ADD COLUMN IF NOT EXISTS displayed_image_id UUID;
|
|
|
|
CREATE INDEX idx_projects_sequence ON projects.projects(sequence);
|
|
CREATE INDEX idx_projects_favorite ON projects.projects(is_favorite) WHERE is_favorite = TRUE;
|
|
CREATE INDEX idx_tasks_sequence ON projects.tasks(project_id, sequence);
|
|
CREATE INDEX idx_tasks_kanban_state ON projects.tasks(kanban_state);
|
|
|
|
-- =====================================================
|
|
-- COR-058: Task Compute Functions
|
|
-- Funciones para calcular campos de tareas
|
|
-- =====================================================
|
|
|
|
-- Funcion para actualizar conteo de tareas en proyecto
|
|
CREATE OR REPLACE FUNCTION projects.update_project_task_count()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
v_project_id UUID;
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
v_project_id := OLD.project_id;
|
|
ELSE
|
|
v_project_id := NEW.project_id;
|
|
END IF;
|
|
|
|
UPDATE projects.projects
|
|
SET task_count = (
|
|
SELECT COUNT(*) FROM projects.tasks
|
|
WHERE project_id = v_project_id AND deleted_at IS NULL
|
|
),
|
|
open_task_count = (
|
|
SELECT COUNT(*) FROM projects.tasks
|
|
WHERE project_id = v_project_id
|
|
AND status NOT IN ('done', 'cancelled')
|
|
AND deleted_at IS NULL
|
|
),
|
|
closed_task_count = (
|
|
SELECT COUNT(*) FROM projects.tasks
|
|
WHERE project_id = v_project_id
|
|
AND status IN ('done', 'cancelled')
|
|
AND deleted_at IS NULL
|
|
)
|
|
WHERE id = v_project_id;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER trg_tasks_update_project_count
|
|
AFTER INSERT OR UPDATE OR DELETE ON projects.tasks
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION projects.update_project_task_count();
|
|
|
|
COMMENT ON FUNCTION projects.update_project_task_count IS 'COR-058: Update task counts in project';
|
|
|
|
-- =====================================================
|
|
-- COR-059: Project Rating
|
|
-- Soporte basico para ratings de proyectos/tareas
|
|
-- =====================================================
|
|
|
|
CREATE TABLE projects.ratings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Modelo y recurso evaluado
|
|
res_model VARCHAR(100) NOT NULL,
|
|
res_id UUID NOT NULL,
|
|
|
|
-- Rating (1-5 estrellas o 1-10)
|
|
rating DECIMAL(3,1) NOT NULL CHECK (rating >= 0 AND rating <= 10),
|
|
|
|
-- Comentarios
|
|
feedback TEXT,
|
|
|
|
-- Partner que evalua
|
|
partner_id UUID REFERENCES core.partners(id),
|
|
|
|
-- Auditoria
|
|
create_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
is_published BOOLEAN DEFAULT TRUE,
|
|
|
|
-- Estado
|
|
consumed BOOLEAN DEFAULT FALSE
|
|
);
|
|
|
|
CREATE INDEX idx_project_ratings_tenant ON projects.ratings(tenant_id);
|
|
CREATE INDEX idx_project_ratings_model_id ON projects.ratings(res_model, res_id);
|
|
CREATE INDEX idx_project_ratings_partner ON projects.ratings(partner_id);
|
|
|
|
-- RLS
|
|
ALTER TABLE projects.ratings ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_project_ratings ON projects.ratings
|
|
USING (tenant_id = get_current_tenant_id());
|
|
|
|
COMMENT ON TABLE projects.ratings IS 'COR-059: Project and task ratings';
|
|
|
|
-- =====================================================
|
|
-- COR-060: Burndown Chart Data
|
|
-- Datos para graficos de burndown
|
|
-- =====================================================
|
|
|
|
CREATE TABLE projects.burndown_chart_data (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
date DATE NOT NULL,
|
|
|
|
-- Metricas
|
|
total_tasks INTEGER DEFAULT 0,
|
|
completed_tasks INTEGER DEFAULT 0,
|
|
remaining_tasks INTEGER DEFAULT 0,
|
|
total_hours DECIMAL(10,2) DEFAULT 0,
|
|
completed_hours DECIMAL(10,2) DEFAULT 0,
|
|
remaining_hours DECIMAL(10,2) DEFAULT 0,
|
|
|
|
-- Auditoria
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(project_id, date)
|
|
);
|
|
|
|
CREATE INDEX idx_burndown_project_date ON projects.burndown_chart_data(project_id, date DESC);
|
|
|
|
COMMENT ON TABLE projects.burndown_chart_data IS 'COR-060: Burndown chart historical data';
|
|
|
|
-- Funcion para generar snapshot de burndown
|
|
CREATE OR REPLACE FUNCTION projects.generate_burndown_snapshot(p_project_id UUID)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_snapshot_id UUID;
|
|
v_total_tasks INTEGER;
|
|
v_completed_tasks INTEGER;
|
|
v_total_hours DECIMAL;
|
|
v_completed_hours DECIMAL;
|
|
BEGIN
|
|
-- Calcular metricas
|
|
SELECT
|
|
COUNT(*),
|
|
COUNT(*) FILTER (WHERE status = 'done'),
|
|
COALESCE(SUM(planned_hours), 0),
|
|
COALESCE(SUM(actual_hours), 0)
|
|
INTO v_total_tasks, v_completed_tasks, v_total_hours, v_completed_hours
|
|
FROM projects.tasks
|
|
WHERE project_id = p_project_id AND deleted_at IS NULL;
|
|
|
|
-- Insertar o actualizar snapshot
|
|
INSERT INTO projects.burndown_chart_data (
|
|
project_id, date, total_tasks, completed_tasks, remaining_tasks,
|
|
total_hours, completed_hours, remaining_hours
|
|
) VALUES (
|
|
p_project_id, CURRENT_DATE, v_total_tasks, v_completed_tasks,
|
|
v_total_tasks - v_completed_tasks,
|
|
v_total_hours, v_completed_hours, v_total_hours - v_completed_hours
|
|
)
|
|
ON CONFLICT (project_id, date) DO UPDATE SET
|
|
total_tasks = EXCLUDED.total_tasks,
|
|
completed_tasks = EXCLUDED.completed_tasks,
|
|
remaining_tasks = EXCLUDED.remaining_tasks,
|
|
total_hours = EXCLUDED.total_hours,
|
|
completed_hours = EXCLUDED.completed_hours,
|
|
remaining_hours = EXCLUDED.remaining_hours
|
|
RETURNING id INTO v_snapshot_id;
|
|
|
|
RETURN v_snapshot_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION projects.generate_burndown_snapshot IS 'COR-060: Generate daily burndown chart snapshot';
|
|
|
|
-- =====================================================
|
|
-- FIN DEL SCHEMA PROJECTS
|
|
-- =====================================================
|