# SPEC-TAREAS-RECURRENTES ## Metadatos | Campo | Valor | |-------|-------| | **Código** | SPEC-TRANS-015 | | **Versión** | 1.0.0 | | **Fecha** | 2025-01-15 | | **Autor** | Requirements-Analyst Agent | | **Estado** | DRAFT | | **Prioridad** | P0 | | **Módulos Afectados** | MGN-011 (Proyectos) | | **Gaps Cubiertos** | GAP-MGN-011-001 | ## Resumen Ejecutivo Esta especificación define el sistema de tareas recurrentes para ERP Core: 1. **Recurrencia Automática**: Generación de tareas en intervalos configurables 2. **Patrones de Recurrencia**: Diario, semanal, mensual, anual con reglas personalizadas 3. **Templates de Tareas**: Plantillas ocultas para configuración de recurrencia 4. **Scheduler**: Cron job para creación automática de tareas 5. **Condiciones de Fin**: Forever, hasta fecha, o número máximo de repeticiones ### Referencia Odoo 18 Basado en análisis del módulo `project` de Odoo 18: - **project.task.recurrence**: Modelo de reglas de recurrencia - **project.task** con `recurring_task=True`: Templates ocultos - Diseño mejorado en PR #98088: templates separados de instancias --- ## Parte 1: Arquitectura del Sistema ### 1.1 Visión General ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SISTEMA DE TAREAS RECURRENTES │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Usuario │───▶│ Configura │───▶│ Recurrence │ │ │ │ │ │ Recurrencia │ │ Rules │ │ │ └──────────────┘ └──────────────┘ └──────┬───────┘ │ │ │ │ │ ▼ │ │ ┌────────────────────────────────┐ │ │ │ Template Task (hidden) │ │ │ │ recurring_task = true │ │ │ └────────────────┬───────────────┘ │ │ │ │ │ ┌────────────────────────────────────┼──────────────┐ │ │ │ │ │ │ │ │ Scheduled Job (Cron) │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Task #1 │ │ Task #2 │ │ Task #N │ │ │ │ (visible) │ │ (visible) │ ... │ (visible) │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Flujo de Recurrencia ``` 1. CONFIGURAR RECURRENCIA ├─ Usuario abre tarea ├─ Activa botón "(Recurrent)" └─ Define patrón (cada X días/semanas/meses/años) │ ▼ 2. CREAR TEMPLATE ├─ Sistema crea task_recurrence record ├─ Marca tarea como template (recurring_task=true) └─ Template queda oculto en vistas │ ▼ 3. SCHEDULER EJECUTA ├─ Cron job revisa recurrencias activas ├─ Calcula próxima fecha de ocurrencia └─ Si fecha <= hoy → crear nueva tarea │ ▼ 4. GENERAR TAREA ├─ Copiar campos del template ├─ Calcular nuevo deadline └─ Nueva tarea visible para usuario │ ▼ 5. COMPLETAR / REPETIR ├─ Usuario completa tarea ├─ Scheduler genera siguiente └─ Loop hasta condición de fin ``` --- ## Parte 2: Modelo de Datos ### 2.1 Diagrama Entidad-Relación ``` ┌─────────────────────────┐ │ task_recurrences │ │─────────────────────────│ │ id (PK) │ │ name │ │ repeat_interval │ │ repeat_unit │ │ repeat_type │ │ repeat_until │ │ repeat_count │ │ repeat_day │◀────────────┐ │ repeat_weekdays │ │ │ template_task_id (FK) │─────┐ │ │ last_occurrence │ │ │ │ next_occurrence │ │ │ │ active │ │ │ └─────────────────────────┘ │ │ │ │ ┌─────────────────┘ │ ▼ │ ┌─────────────────────────┐ │ │ project_tasks │ │ │─────────────────────────│ │ │ id (PK) │ │ │ name │ │ │ project_id (FK) │ │ │ user_id (FK) │ │ │ date_deadline │ │ │ state │ │ │ ... │ │ │ recurring_task (bool) │─────────────┘ │ recurrence_id (FK) │──────────────▶ task_recurrences └─────────────────────────┘ ``` ### 2.2 Definición de Tablas #### task_recurrences (Reglas de Recurrencia) ```sql -- Reglas de recurrencia para tareas CREATE TABLE task_recurrences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(255), -- Intervalo de repetición repeat_interval INTEGER NOT NULL DEFAULT 1, repeat_unit recurrence_unit NOT NULL DEFAULT 'week', -- Condición de fin repeat_type recurrence_end_type NOT NULL DEFAULT 'forever', repeat_until DATE, -- Usado si repeat_type = 'until' repeat_count INTEGER, -- Usado si repeat_type = 'count' occurrence_count INTEGER NOT NULL DEFAULT 0, -- Contador actual -- Configuración específica por unidad -- Para repeat_unit = 'month' repeat_day INTEGER CHECK (repeat_day BETWEEN 1 AND 31), repeat_on_month repeat_month_basis DEFAULT 'date', -- 'date' = mismo día del mes (ej: día 15) -- 'day' = mismo día de semana (ej: segundo martes) -- Para repeat_unit = 'week' repeat_weekdays SMALLINT DEFAULT 0, -- Bitmask: Dom=1, Lun=2, Mar=4... -- Ejemplo: Lun+Mié+Vie = 2+8+32 = 42 -- Para repeat_unit = 'year' repeat_month INTEGER CHECK (repeat_month BETWEEN 1 AND 12), -- Anticipación create_before_deadline INTEGER DEFAULT 0, -- Días antes del deadline -- Template template_task_id UUID NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE, -- Control de ejecución last_occurrence_date DATE, next_occurrence_date DATE NOT NULL, active BOOLEAN NOT NULL DEFAULT true, -- Auditoría company_id UUID NOT NULL REFERENCES companies(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id), updated_by UUID NOT NULL REFERENCES users(id) ); CREATE TYPE recurrence_unit AS ENUM ('day', 'week', 'month', 'year'); CREATE TYPE recurrence_end_type AS ENUM ('forever', 'until', 'count'); CREATE TYPE repeat_month_basis AS ENUM ('date', 'day'); CREATE INDEX idx_task_recurrences_next ON task_recurrences(next_occurrence_date) WHERE active = true; CREATE INDEX idx_task_recurrences_template ON task_recurrences(template_task_id); ``` #### Extensión de project_tasks ```sql -- Agregar campos de recurrencia a tareas ALTER TABLE project_tasks ADD COLUMN IF NOT EXISTS recurring_task BOOLEAN NOT NULL DEFAULT false; ALTER TABLE project_tasks ADD COLUMN IF NOT EXISTS recurrence_id UUID REFERENCES task_recurrences(id) ON DELETE SET NULL; -- Índice para filtrar templates CREATE INDEX idx_project_tasks_recurring ON project_tasks(recurring_task); -- Las vistas deben filtrar: WHERE recurring_task = false COMMENT ON COLUMN project_tasks.recurring_task IS 'True para tareas template (ocultas). False para tareas normales visibles.'; ``` --- ## Parte 3: Servicios de Aplicación ### 3.1 TaskRecurrenceService ```typescript // src/modules/project/services/task-recurrence.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, LessThanOrEqual } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { TaskRecurrence, RecurrenceUnit, RecurrenceEndType } from '../entities/task-recurrence.entity'; import { ProjectTask } from '../entities/project-task.entity'; @Injectable() export class TaskRecurrenceService { constructor( @InjectRepository(TaskRecurrence) private readonly recurrenceRepo: Repository, @InjectRepository(ProjectTask) private readonly taskRepo: Repository, private readonly dataSource: DataSource, ) {} /** * Configurar recurrencia en una tarea existente */ async setupRecurrence( taskId: string, dto: SetupRecurrenceDto, userId: string ): Promise { const task = await this.taskRepo.findOneOrFail({ where: { id: taskId } }); // Validar que no sea subtarea if (task.parentId) { throw new BadRequestException('Las subtareas no pueden tener recurrencia'); } // Validar que no tenga ya recurrencia if (task.recurrenceId) { throw new BadRequestException('La tarea ya tiene recurrencia configurada'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Calcular primera ocurrencia const nextDate = this.calculateNextOccurrence( task.dateDeadline || new Date(), dto ); // Crear regla de recurrencia const recurrence = this.recurrenceRepo.create({ name: `Recurrencia: ${task.name}`, repeatInterval: dto.repeatInterval, repeatUnit: dto.repeatUnit, repeatType: dto.repeatType, repeatUntil: dto.repeatUntil, repeatCount: dto.repeatCount, repeatDay: dto.repeatDay, repeatOnMonth: dto.repeatOnMonth, repeatWeekdays: dto.repeatWeekdays, repeatMonth: dto.repeatMonth, createBeforeDeadline: dto.createBeforeDeadline || 0, templateTaskId: task.id, nextOccurrenceDate: nextDate, companyId: task.companyId, createdBy: userId, updatedBy: userId, }); const savedRecurrence = await queryRunner.manager.save(recurrence); // Marcar tarea como template (oculta) task.recurringTask = true; task.recurrenceId = savedRecurrence.id; task.updatedBy = userId; await queryRunner.manager.save(task); // Crear primera tarea visible await this.createTaskFromTemplate(task, savedRecurrence, queryRunner, userId); await queryRunner.commitTransaction(); return savedRecurrence; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Calcular próxima fecha de ocurrencia */ calculateNextOccurrence( baseDate: Date, config: RecurrenceConfig ): Date { const date = new Date(baseDate); switch (config.repeatUnit) { case RecurrenceUnit.DAY: date.setDate(date.getDate() + config.repeatInterval); break; case RecurrenceUnit.WEEK: if (config.repeatWeekdays) { // Encontrar próximo día habilitado date.setDate(date.getDate() + 1); while (!this.isDayEnabled(date, config.repeatWeekdays)) { date.setDate(date.getDate() + 1); } } else { date.setDate(date.getDate() + (config.repeatInterval * 7)); } break; case RecurrenceUnit.MONTH: if (config.repeatOnMonth === 'date') { // Mismo día del mes date.setMonth(date.getMonth() + config.repeatInterval); if (config.repeatDay) { // Ajustar al día específico, manejando meses cortos const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); date.setDate(Math.min(config.repeatDay, maxDay)); } } else { // Mismo día de semana (ej: segundo martes) const weekOfMonth = Math.ceil(baseDate.getDate() / 7); const dayOfWeek = baseDate.getDay(); date.setMonth(date.getMonth() + config.repeatInterval); date.setDate(1); // Encontrar el N-ésimo día de la semana while (date.getDay() !== dayOfWeek) { date.setDate(date.getDate() + 1); } date.setDate(date.getDate() + (weekOfMonth - 1) * 7); } break; case RecurrenceUnit.YEAR: date.setFullYear(date.getFullYear() + config.repeatInterval); if (config.repeatMonth) { date.setMonth(config.repeatMonth - 1); } if (config.repeatDay) { const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); date.setDate(Math.min(config.repeatDay, maxDay)); } break; } return date; } /** * Verificar si un día está habilitado en bitmask de weekdays */ private isDayEnabled(date: Date, weekdaysMask: number): boolean { const dayBit = 1 << date.getDay(); // Dom=1, Lun=2, Mar=4... return (weekdaysMask & dayBit) !== 0; } /** * Crear tarea desde template */ async createTaskFromTemplate( template: ProjectTask, recurrence: TaskRecurrence, queryRunner: any, userId: string ): Promise { // Calcular deadline de nueva tarea let newDeadline: Date | null = null; if (template.dateDeadline) { // Mantener el delta entre creación y deadline original const creationDate = template.createdAt; const originalDeadline = template.dateDeadline; const deltaMs = originalDeadline.getTime() - creationDate.getTime(); newDeadline = new Date(recurrence.nextOccurrenceDate); newDeadline.setTime(newDeadline.getTime() + deltaMs); } // Crear nueva tarea (copia de template) const newTask = this.taskRepo.create({ name: template.name, description: template.description, projectId: template.projectId, userId: template.userId, assigneeIds: template.assigneeIds, partnerId: template.partnerId, tagIds: template.tagIds, priority: template.priority, dateDeadline: newDeadline, plannedHours: template.plannedHours, // NO copiar: // - milestoneId // - parentId (subtareas) // - timesheets recurringTask: false, // Esta es visible recurrenceId: recurrence.id, companyId: template.companyId, createdBy: userId, updatedBy: userId, }); return queryRunner.manager.save(newTask); } /** * Job programado para crear tareas recurrentes * Ejecuta cada hora */ @Cron(CronExpression.EVERY_HOUR) async processRecurringTasks(): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); // Buscar recurrencias activas con próxima fecha <= hoy const dueRecurrences = await this.recurrenceRepo.find({ where: { active: true, nextOccurrenceDate: LessThanOrEqual(today), }, relations: ['templateTask'], }); for (const recurrence of dueRecurrences) { await this.processRecurrence(recurrence); } } /** * Procesar una recurrencia individual */ private async processRecurrence(recurrence: TaskRecurrence): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Verificar condición de fin if (this.shouldStopRecurrence(recurrence)) { recurrence.active = false; await queryRunner.manager.save(recurrence); await queryRunner.commitTransaction(); return; } // Crear nueva tarea await this.createTaskFromTemplate( recurrence.templateTask, recurrence, queryRunner, recurrence.createdBy // Sistema ); // Actualizar recurrencia recurrence.lastOccurrenceDate = recurrence.nextOccurrenceDate; recurrence.nextOccurrenceDate = this.calculateNextOccurrence( recurrence.nextOccurrenceDate, { repeatInterval: recurrence.repeatInterval, repeatUnit: recurrence.repeatUnit, repeatDay: recurrence.repeatDay, repeatOnMonth: recurrence.repeatOnMonth, repeatWeekdays: recurrence.repeatWeekdays, repeatMonth: recurrence.repeatMonth, } ); recurrence.occurrenceCount += 1; await queryRunner.manager.save(recurrence); await queryRunner.commitTransaction(); } catch (error) { await queryRunner.rollbackTransaction(); console.error(`Error procesando recurrencia ${recurrence.id}:`, error); } finally { await queryRunner.release(); } } /** * Verificar si debe detenerse la recurrencia */ private shouldStopRecurrence(recurrence: TaskRecurrence): boolean { switch (recurrence.repeatType) { case RecurrenceEndType.FOREVER: return false; case RecurrenceEndType.UNTIL: return recurrence.repeatUntil && recurrence.nextOccurrenceDate > recurrence.repeatUntil; case RecurrenceEndType.COUNT: return recurrence.repeatCount && recurrence.occurrenceCount >= recurrence.repeatCount; default: return false; } } /** * Detener recurrencia */ async stopRecurrence(taskId: string, userId: string): Promise { const task = await this.taskRepo.findOneOrFail({ where: { id: taskId }, relations: ['recurrence'] }); if (!task.recurrenceId) { throw new BadRequestException('La tarea no tiene recurrencia'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Desactivar recurrencia await queryRunner.manager.update(TaskRecurrence, { id: task.recurrenceId }, { active: false, updatedBy: userId } ); // Eliminar template si existe const recurrence = task.recurrence; if (recurrence?.templateTaskId) { // Eliminar template (las tareas hijas quedan con recurrence_id = null) await queryRunner.manager.delete(ProjectTask, { id: recurrence.templateTaskId }); } await queryRunner.commitTransaction(); } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Obtener todas las tareas de una recurrencia */ async getRecurrenceTasks(recurrenceId: string): Promise { return this.taskRepo.find({ where: { recurrenceId, recurringTask: false, // Solo visibles }, order: { dateDeadline: 'ASC' }, }); } /** * Actualizar template (afecta futuras ocurrencias) */ async updateRecurrenceTemplate( recurrenceId: string, dto: UpdateTemplateDto, userId: string ): Promise { const recurrence = await this.recurrenceRepo.findOneOrFail({ where: { id: recurrenceId }, relations: ['templateTask'] }); // Actualizar template Object.assign(recurrence.templateTask, dto); recurrence.templateTask.updatedBy = userId; return this.taskRepo.save(recurrence.templateTask); } } ``` ### 3.2 DTOs y Validaciones ```typescript // src/modules/project/dto/task-recurrence.dto.ts export class SetupRecurrenceDto { @IsInt() @Min(1) @Max(365) repeatInterval: number; @IsEnum(RecurrenceUnit) repeatUnit: RecurrenceUnit; @IsEnum(RecurrenceEndType) repeatType: RecurrenceEndType; @IsDate() @IsOptional() @ValidateIf(o => o.repeatType === RecurrenceEndType.UNTIL) repeatUntil?: Date; @IsInt() @Min(1) @Max(999) @IsOptional() @ValidateIf(o => o.repeatType === RecurrenceEndType.COUNT) repeatCount?: number; @IsInt() @Min(1) @Max(31) @IsOptional() repeatDay?: number; @IsEnum(RepeatMonthBasis) @IsOptional() repeatOnMonth?: RepeatMonthBasis; @IsInt() @Min(0) @Max(127) // Bitmask para 7 días @IsOptional() repeatWeekdays?: number; @IsInt() @Min(1) @Max(12) @IsOptional() repeatMonth?: number; @IsInt() @Min(0) @Max(30) @IsOptional() createBeforeDeadline?: number; } // Helper para weekdays export const WEEKDAY_FLAGS = { SUNDAY: 1, // 2^0 MONDAY: 2, // 2^1 TUESDAY: 4, // 2^2 WEDNESDAY: 8, // 2^3 THURSDAY: 16, // 2^4 FRIDAY: 32, // 2^5 SATURDAY: 64, // 2^6 }; // Ejemplo: Lunes, Miércoles, Viernes // MONDAY | WEDNESDAY | FRIDAY = 2 | 8 | 32 = 42 ``` --- ## Parte 4: API REST ### 4.1 Endpoints ```typescript // src/modules/project/controllers/task-recurrence.controller.ts @Controller('api/v1/tasks/:taskId/recurrence') @UseGuards(JwtAuthGuard) @ApiTags('Task Recurrence') export class TaskRecurrenceController { constructor( private readonly recurrenceService: TaskRecurrenceService, ) {} @Post() @ApiOperation({ summary: 'Configurar recurrencia en tarea' }) async setupRecurrence( @Param('taskId', ParseUUIDPipe) taskId: string, @Body() dto: SetupRecurrenceDto, @CurrentUser() user: User, ): Promise { const recurrence = await this.recurrenceService.setupRecurrence( taskId, dto, user.id ); return this.mapToResponse(recurrence); } @Get() @ApiOperation({ summary: 'Obtener configuración de recurrencia' }) async getRecurrence( @Param('taskId', ParseUUIDPipe) taskId: string, ): Promise { return this.recurrenceService.getRecurrenceByTask(taskId); } @Patch() @ApiOperation({ summary: 'Actualizar recurrencia' }) async updateRecurrence( @Param('taskId', ParseUUIDPipe) taskId: string, @Body() dto: UpdateRecurrenceDto, @CurrentUser() user: User, ): Promise { return this.recurrenceService.updateRecurrence(taskId, dto, user.id); } @Delete() @ApiOperation({ summary: 'Detener recurrencia' }) async stopRecurrence( @Param('taskId', ParseUUIDPipe) taskId: string, @CurrentUser() user: User, ): Promise { return this.recurrenceService.stopRecurrence(taskId, user.id); } @Get('tasks') @ApiOperation({ summary: 'Listar todas las tareas de la recurrencia' }) async listRecurrenceTasks( @Param('taskId', ParseUUIDPipe) taskId: string, ): Promise { const task = await this.recurrenceService.getTaskById(taskId); if (!task.recurrenceId) { throw new BadRequestException('La tarea no tiene recurrencia'); } return this.recurrenceService.getRecurrenceTasks(task.recurrenceId); } @Post('generate-next') @ApiOperation({ summary: 'Generar siguiente tarea manualmente (debug)' }) @Roles('admin') async generateNext( @Param('taskId', ParseUUIDPipe) taskId: string, @CurrentUser() user: User, ): Promise { return this.recurrenceService.generateNextManually(taskId, user.id); } } ``` --- ## Parte 5: Interfaz de Usuario ### 5.1 Componente de Configuración ```typescript // Frontend: RecurrenceConfigModal.tsx interface RecurrenceConfig { repeatInterval: number; repeatUnit: 'day' | 'week' | 'month' | 'year'; repeatType: 'forever' | 'until' | 'count'; repeatUntil?: Date; repeatCount?: number; repeatDay?: number; repeatOnMonth?: 'date' | 'day'; repeatWeekdays?: number; } const WEEKDAYS = [ { label: 'Dom', value: 1 }, { label: 'Lun', value: 2 }, { label: 'Mar', value: 4 }, { label: 'Mié', value: 8 }, { label: 'Jue', value: 16 }, { label: 'Vie', value: 32 }, { label: 'Sáb', value: 64 }, ]; export const RecurrenceConfigModal: React.FC = ({ task, onSave, onCancel }) => { const [config, setConfig] = useState({ repeatInterval: 1, repeatUnit: 'week', repeatType: 'forever', repeatWeekdays: 0, }); const toggleWeekday = (value: number) => { setConfig(prev => ({ ...prev, repeatWeekdays: (prev.repeatWeekdays || 0) ^ value })); }; return (
{/* Intervalo */} setConfig({...config, repeatInterval: v})} /> {/* Días de la semana (solo para semanal) */} {config.repeatUnit === 'week' && (
{WEEKDAYS.map(day => ( toggleWeekday(day.value)} > {day.label} ))}
)} {/* Día del mes (solo para mensual) */} {config.repeatUnit === 'month' && ( setConfig({...config, repeatOnMonth: v})} > Día {config.repeatDay || task.dateDeadline?.getDate()} {getOrdinalWeek(task.dateDeadline)} {getDayName(task.dateDeadline)} )} {/* Condición de fin */} setConfig({...config, repeatType: v})} > Nunca El setConfig({...config, repeatUntil: d})} disabled={config.repeatType !== 'until'} /> Después de setConfig({...config, repeatCount: v})} disabled={config.repeatType !== 'count'} /> ocurrencias
); }; ``` --- ## Parte 6: Consideraciones Especiales ### 6.1 Filtrado de Templates ```typescript // Todas las queries de tareas deben excluir templates const tasks = await this.taskRepo.find({ where: { projectId, recurringTask: false, // IMPORTANTE: excluir templates } }); // O usar scope global en entidad @Entity() @Scope({ recurringTask: false }) export class ProjectTask { ... } ``` ### 6.2 Manejo de Fechas Límite ```typescript // Casos especiales para fechas function handleEdgeCases(date: Date, config: RecurrenceConfig): Date { // Febrero 29 → Febrero 28 en años no bisiestos if (config.repeatUnit === 'year' && date.getMonth() === 1 && date.getDate() === 29) { const year = date.getFullYear(); if (!isLeapYear(year)) { date.setDate(28); } } // Día 31 en meses con 30 días if (config.repeatUnit === 'month' && config.repeatDay === 31) { const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); date.setDate(Math.min(31, maxDay)); } return date; } ``` ### 6.3 Migración de Datos ```sql -- Índice para performance del scheduler CREATE INDEX CONCURRENTLY idx_task_recurrences_pending ON task_recurrences(next_occurrence_date) WHERE active = true; -- Vista para dashboard de recurrencias CREATE VIEW v_recurring_tasks_summary AS SELECT r.id, r.name, r.repeat_interval, r.repeat_unit, r.next_occurrence_date, COUNT(t.id) as total_occurrences, MAX(t.date_deadline) as last_task_deadline FROM task_recurrences r LEFT JOIN project_tasks t ON t.recurrence_id = r.id AND t.recurring_task = false WHERE r.active = true GROUP BY r.id; ``` --- ## Apéndice A: Patrones de Recurrencia Comunes | Patrón | Configuración | |--------|---------------| | Diario | interval=1, unit=day | | Cada 3 días | interval=3, unit=day | | Semanal (mismo día) | interval=1, unit=week | | Lun-Mié-Vie | interval=1, unit=week, weekdays=42 | | Quincenal | interval=2, unit=week | | Mensual (día fijo) | interval=1, unit=month, onMonth=date | | Mensual (2do martes) | interval=1, unit=month, onMonth=day | | Trimestral | interval=3, unit=month | | Anual | interval=1, unit=year | --- ## Apéndice B: Checklist de Implementación - [ ] Modelo task_recurrences (migración) - [ ] Extensión project_tasks (campos recurrencia) - [ ] TaskRecurrenceService - [ ] Cron job para procesamiento - [ ] Controlador REST - [ ] DTOs y validaciones - [ ] Filtrado de templates en queries - [ ] UI: Modal de configuración - [ ] UI: Badge de recurrencia en tarjeta - [ ] UI: Lista de ocurrencias - [ ] Tests unitarios - [ ] Tests del scheduler --- *Documento generado como parte del análisis de gaps ERP Core vs Odoo 18* *Referencia: GAP-MGN-011-001 - Tareas Recurrentes*