From 450b13edff7dcf970cc3e03e19b7c4251593d090 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 00:21:23 -0600 Subject: [PATCH] feat(progress): Add ProgramaObraService for work schedule management Sprint 2 - S2-T01 backend support New service with methods: - createPrograma: Create new work schedule with auto-generated code - findAll/findById: List and get programas with actividades - updatePrograma: Update programa details - createVersion: Create new version for reprogramming - addActividad/updateActividad/removeActividad: Activity management - reorderActividades: Reorder with WBS code regeneration - getGanttData: Data formatted for Gantt chart - getSCurveData: Planned vs actual progress curves DTOs: CreateProgramaDto, UpdateProgramaDto, CreateActividadDto, etc. Co-Authored-By: Claude Opus 4.5 --- src/modules/progress/services/index.ts | 14 + .../services/programa-obra.service.ts | 1266 +++++++++++++++++ 2 files changed, 1280 insertions(+) create mode 100644 src/modules/progress/services/programa-obra.service.ts diff --git a/src/modules/progress/services/index.ts b/src/modules/progress/services/index.ts index 47e5790..415d51c 100644 --- a/src/modules/progress/services/index.ts +++ b/src/modules/progress/services/index.ts @@ -8,3 +8,17 @@ export type { CreateAvanceDto, AddFotoDto, AvanceFilters } from './avance-obra.s export { BitacoraObraService } from './bitacora-obra.service'; export type { CreateBitacoraDto, UpdateBitacoraDto, BitacoraFilters } from './bitacora-obra.service'; + +export { ProgramaObraService } from './programa-obra.service'; +export type { + CreateProgramaDto, + UpdateProgramaDto, + CreateActividadDto, + UpdateActividadDto, + ProgramaFilters, + GanttData, + GanttActivity, + SCurveData, + SCurveDataPoint, + ReorderItem, +} from './programa-obra.service'; diff --git a/src/modules/progress/services/programa-obra.service.ts b/src/modules/progress/services/programa-obra.service.ts new file mode 100644 index 0000000..6d5ce0d --- /dev/null +++ b/src/modules/progress/services/programa-obra.service.ts @@ -0,0 +1,1266 @@ +/** + * ProgramaObraService - Gestion de Programas de Obra + * + * Gestiona programas maestros de obra (planificacion), actividades WBS, + * versionamiento para reprogramaciones, y datos para Gantt y Curva S. + * + * @module Progress + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { ProgramaObra } from '../entities/programa-obra.entity'; +import { ProgramaActividad } from '../entities/programa-actividad.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { AvanceObra } from '../entities/avance-obra.entity'; + +/** + * Service context for multi-tenant operations + */ +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * DTO for creating a programa de obra + */ +export interface CreateProgramaDto { + fraccionamientoId: string; + name: string; + startDate: Date; + endDate: Date; +} + +/** + * DTO for updating a programa de obra + */ +export interface UpdateProgramaDto { + name?: string; + startDate?: Date; + endDate?: Date; + isActive?: boolean; +} + +/** + * DTO for creating an actividad + */ +export interface CreateActividadDto { + name: string; + conceptoId?: string; + parentId?: string; + plannedStart?: Date; + plannedEnd?: Date; + plannedQuantity?: number; + plannedWeight?: number; +} + +/** + * DTO for updating an actividad + */ +export interface UpdateActividadDto { + name?: string; + conceptoId?: string; + parentId?: string; + plannedStart?: Date; + plannedEnd?: Date; + plannedQuantity?: number; + plannedWeight?: number; +} + +/** + * Filters for programa queries + */ +export interface ProgramaFilters { + fraccionamientoId?: string; + isActive?: boolean; + search?: string; +} + +/** + * Gantt chart activity data + */ +export interface GanttActivity { + id: string; + name: string; + wbsCode: string | null; + parentId: string | null; + conceptoId: string | null; + conceptoCode: string | null; + plannedStart: Date | null; + plannedEnd: Date | null; + plannedQuantity: number; + plannedWeight: number; + actualProgress: number; + actualQuantity: number; + sequence: number; + children: GanttActivity[]; +} + +/** + * Gantt chart data structure + */ +export interface GanttData { + programaId: string; + programaName: string; + programaCode: string; + version: number; + startDate: Date; + endDate: Date; + activities: GanttActivity[]; + totalPlannedWeight: number; + totalActualProgress: number; +} + +/** + * S-Curve data point + */ +export interface SCurveDataPoint { + date: Date; + plannedCumulative: number; + actualCumulative: number; + plannedPercent: number; + actualPercent: number; + variance: number; +} + +/** + * S-Curve data structure + */ +export interface SCurveData { + programaId: string; + programaName: string; + totalPlannedWeight: number; + dataPoints: SCurveDataPoint[]; + currentVariance: number; + status: 'ahead' | 'on_track' | 'behind' | 'critical'; +} + +/** + * Reorder item structure + */ +export interface ReorderItem { + actividadId: string; + sequence: number; + parentId?: string | null; +} + +export class ProgramaObraService { + private readonly programaRepository: Repository; + private readonly actividadRepository: Repository; + private readonly fraccionamientoRepository: Repository; + private readonly conceptoRepository: Repository; + private readonly avanceRepository: Repository; + private readonly dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.programaRepository = dataSource.getRepository(ProgramaObra); + this.actividadRepository = dataSource.getRepository(ProgramaActividad); + this.fraccionamientoRepository = dataSource.getRepository(Fraccionamiento); + this.conceptoRepository = dataSource.getRepository(Concepto); + this.avanceRepository = dataSource.getRepository(AvanceObra); + } + + // ============ PROGRAMA METHODS ============ + + /** + * Create a new programa de obra + * Generates code PRG-YYYY-NNNNN, sets version to 1, isActive to true + */ + async createPrograma(ctx: ServiceContext, dto: CreateProgramaDto): Promise { + // Validate fraccionamiento exists + const fraccionamiento = await this.fraccionamientoRepository.findOne({ + where: { + id: dto.fraccionamientoId, + tenantId: ctx.tenantId, + }, + }); + + if (!fraccionamiento) { + throw new Error(`Fraccionamiento with id '${dto.fraccionamientoId}' not found`); + } + + // Generate code: PRG-YYYY-NNNNN + const code = await this.generateProgramaCode(ctx); + + const programa = this.programaRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + code, + name: dto.name, + startDate: dto.startDate, + endDate: dto.endDate, + version: 1, + isActive: true, + createdById: ctx.userId, + }); + + return this.programaRepository.save(programa); + } + + /** + * Find all programas with filters and pagination + */ + async findAll( + ctx: ServiceContext, + filters: ProgramaFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.programaRepository + .createQueryBuilder('p') + .leftJoinAndSelect('p.fraccionamiento', 'f') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (filters.fraccionamientoId) { + qb.andWhere('p.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('p.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(p.code ILIKE :search OR p.name ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const skip = (page - 1) * limit; + qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Find programa by ID with actividades + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.programaRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['fraccionamiento', 'actividades', 'actividades.concepto'], + }); + } + + /** + * Update programa details + */ + async updatePrograma( + ctx: ServiceContext, + id: string, + dto: UpdateProgramaDto + ): Promise { + const programa = await this.programaRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!programa) { + return null; + } + + if (dto.name !== undefined) { + programa.name = dto.name; + } + if (dto.startDate !== undefined) { + programa.startDate = dto.startDate; + } + if (dto.endDate !== undefined) { + programa.endDate = dto.endDate; + } + if (dto.isActive !== undefined) { + programa.isActive = dto.isActive; + } + + programa.updatedById = ctx.userId ?? null; + + return this.programaRepository.save(programa); + } + + /** + * Create new version of programa (reprogramming) + * Copies all actividades from previous version + */ + async createVersion(ctx: ServiceContext, id: string): Promise { + return this.dataSource.transaction(async (manager) => { + const programaRepo = manager.getRepository(ProgramaObra); + const actividadRepo = manager.getRepository(ProgramaActividad); + + // Find current programa + const currentPrograma = await programaRepo.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['actividades'], + }); + + if (!currentPrograma) { + throw new Error(`Programa with id '${id}' not found`); + } + + // Deactivate current version + currentPrograma.isActive = false; + currentPrograma.updatedById = ctx.userId ?? null; + await programaRepo.save(currentPrograma); + + // Create new version + const newPrograma = programaRepo.create({ + tenantId: ctx.tenantId, + fraccionamientoId: currentPrograma.fraccionamientoId, + code: currentPrograma.code, + name: currentPrograma.name, + startDate: currentPrograma.startDate, + endDate: currentPrograma.endDate, + version: currentPrograma.version + 1, + isActive: true, + createdById: ctx.userId, + }); + + const savedPrograma = await programaRepo.save(newPrograma); + + // Copy actividades with parent-child mapping + if (currentPrograma.actividades && currentPrograma.actividades.length > 0) { + const oldToNewIdMap = new Map(); + + // First pass: create actividades without parent references + const actividadesSorted = this.sortActividadesByHierarchy(currentPrograma.actividades); + + for (const oldActividad of actividadesSorted) { + const newActividad = actividadRepo.create({ + tenantId: ctx.tenantId, + programaId: savedPrograma.id, + conceptoId: oldActividad.conceptoId, + parentId: null, // Will be set in second pass + name: oldActividad.name, + sequence: oldActividad.sequence, + plannedStart: oldActividad.plannedStart, + plannedEnd: oldActividad.plannedEnd, + plannedQuantity: oldActividad.plannedQuantity, + plannedWeight: oldActividad.plannedWeight, + wbsCode: oldActividad.wbsCode, + createdById: ctx.userId, + }); + + const savedActividad = await actividadRepo.save(newActividad); + oldToNewIdMap.set(oldActividad.id, savedActividad.id); + } + + // Second pass: update parent references + for (const oldActividad of actividadesSorted) { + if (oldActividad.parentId) { + const newId = oldToNewIdMap.get(oldActividad.id); + const newParentId = oldToNewIdMap.get(oldActividad.parentId); + + if (newId && newParentId) { + await actividadRepo.update( + { id: newId }, + { parentId: newParentId } + ); + } + } + } + } + + return savedPrograma; + }); + } + + // ============ ACTIVIDAD METHODS ============ + + /** + * Add actividad to programa + * Supports parent-child hierarchy with auto-generated WBS code + */ + async addActividad( + ctx: ServiceContext, + programaId: string, + dto: CreateActividadDto + ): Promise { + // Validate programa exists + const programa = await this.programaRepository.findOne({ + where: { + id: programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!programa) { + throw new Error(`Programa with id '${programaId}' not found`); + } + + // Validate concepto if provided + if (dto.conceptoId) { + const concepto = await this.conceptoRepository.findOne({ + where: { + id: dto.conceptoId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!concepto) { + throw new Error(`Concepto with id '${dto.conceptoId}' not found`); + } + } + + // Validate parent if provided + let parentActividad: ProgramaActividad | null = null; + if (dto.parentId) { + parentActividad = await this.actividadRepository.findOne({ + where: { + id: dto.parentId, + programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parentActividad) { + throw new Error(`Parent actividad with id '${dto.parentId}' not found`); + } + } + + // Calculate next sequence + const maxSequence = await this.getMaxSequence(ctx, programaId, dto.parentId ?? null); + + // Generate WBS code + const wbsCode = await this.generateWbsCode(ctx, programaId, dto.parentId ?? null); + + const actividad = this.actividadRepository.create({ + tenantId: ctx.tenantId, + programaId, + conceptoId: dto.conceptoId ?? null, + parentId: dto.parentId ?? null, + name: dto.name, + sequence: maxSequence + 1, + plannedStart: dto.plannedStart ?? null, + plannedEnd: dto.plannedEnd ?? null, + plannedQuantity: dto.plannedQuantity ?? 0, + plannedWeight: dto.plannedWeight ?? 0, + wbsCode, + createdById: ctx.userId, + }); + + return this.actividadRepository.save(actividad); + } + + /** + * Update actividad + */ + async updateActividad( + ctx: ServiceContext, + actividadId: string, + dto: UpdateActividadDto + ): Promise { + const actividad = await this.actividadRepository.findOne({ + where: { + id: actividadId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!actividad) { + return null; + } + + // Validate concepto if changing + if (dto.conceptoId !== undefined && dto.conceptoId !== null) { + const concepto = await this.conceptoRepository.findOne({ + where: { + id: dto.conceptoId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!concepto) { + throw new Error(`Concepto with id '${dto.conceptoId}' not found`); + } + } + + // Validate parent if changing + if (dto.parentId !== undefined && dto.parentId !== null) { + // Prevent self-reference + if (dto.parentId === actividadId) { + throw new Error('Actividad cannot be its own parent'); + } + + const parentActividad = await this.actividadRepository.findOne({ + where: { + id: dto.parentId, + programaId: actividad.programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parentActividad) { + throw new Error(`Parent actividad with id '${dto.parentId}' not found`); + } + + // Prevent circular references + const isCircular = await this.checkCircularReference(actividadId, dto.parentId); + if (isCircular) { + throw new Error('Cannot set parent: would create circular reference'); + } + } + + if (dto.name !== undefined) { + actividad.name = dto.name; + } + if (dto.conceptoId !== undefined) { + actividad.conceptoId = dto.conceptoId; + } + if (dto.parentId !== undefined) { + actividad.parentId = dto.parentId; + // Regenerate WBS code when parent changes + actividad.wbsCode = await this.generateWbsCode( + ctx, + actividad.programaId, + dto.parentId + ); + } + if (dto.plannedStart !== undefined) { + actividad.plannedStart = dto.plannedStart; + } + if (dto.plannedEnd !== undefined) { + actividad.plannedEnd = dto.plannedEnd; + } + if (dto.plannedQuantity !== undefined) { + actividad.plannedQuantity = dto.plannedQuantity; + } + if (dto.plannedWeight !== undefined) { + actividad.plannedWeight = dto.plannedWeight; + } + + actividad.updatedById = ctx.userId ?? null; + + return this.actividadRepository.save(actividad); + } + + /** + * Soft delete actividad + */ + async removeActividad(ctx: ServiceContext, actividadId: string): Promise { + const actividad = await this.actividadRepository.findOne({ + where: { + id: actividadId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!actividad) { + return false; + } + + actividad.deletedAt = new Date(); + actividad.deletedById = ctx.userId ?? null; + + await this.actividadRepository.save(actividad); + + // Also soft delete children + await this.softDeleteChildren(ctx, actividadId); + + return true; + } + + /** + * Reorder actividades + */ + async reorderActividades( + ctx: ServiceContext, + programaId: string, + orden: ReorderItem[] + ): Promise { + // Validate programa exists + const programa = await this.programaRepository.findOne({ + where: { + id: programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!programa) { + throw new Error(`Programa with id '${programaId}' not found`); + } + + return this.dataSource.transaction(async (manager) => { + const actividadRepo = manager.getRepository(ProgramaActividad); + + for (const item of orden) { + await actividadRepo.update( + { + id: item.actividadId, + programaId, + tenantId: ctx.tenantId, + }, + { + sequence: item.sequence, + parentId: item.parentId !== undefined ? item.parentId : undefined, + updatedById: ctx.userId ?? null, + } + ); + } + + // Regenerate WBS codes for all actividades + const actividades = await actividadRepo.find({ + where: { + programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: { sequence: 'ASC' }, + }); + + await this.regenerateAllWbsCodes(ctx, programaId, actividades, actividadRepo); + + return true; + }); + } + + // ============ GANTT METHODS ============ + + /** + * Get data formatted for Gantt chart + */ + async getGanttData(ctx: ServiceContext, programaId: string): Promise { + const programa = await this.programaRepository.findOne({ + where: { + id: programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['actividades', 'actividades.concepto'], + }); + + if (!programa) { + return null; + } + + // Get all actividades with their progress + const actividades = await this.actividadRepository.find({ + where: { + programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['concepto'], + order: { sequence: 'ASC' }, + }); + + // Calculate progress for each actividad from avances + const actividadProgress = await this.calculateActividadesProgress(ctx, actividades); + + // Build hierarchical structure + const ganttActivities = this.buildGanttHierarchy(actividades, actividadProgress); + + // Calculate totals + const totalPlannedWeight = actividades + .filter((a) => !a.parentId) // Only root level for total + .reduce((sum, a) => sum + Number(a.plannedWeight), 0); + + const totalActualProgress = this.calculateWeightedProgress(actividades, actividadProgress); + + return { + programaId: programa.id, + programaName: programa.name, + programaCode: programa.code, + version: programa.version, + startDate: programa.startDate, + endDate: programa.endDate, + activities: ganttActivities, + totalPlannedWeight, + totalActualProgress, + }; + } + + // ============ S-CURVE METHODS ============ + + /** + * Get S-curve data for programa + */ + async getSCurveData( + ctx: ServiceContext, + programaId: string, + periodType: 'daily' | 'weekly' | 'monthly' = 'weekly' + ): Promise { + const programa = await this.programaRepository.findOne({ + where: { + id: programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!programa) { + return null; + } + + // Get all actividades + const actividades = await this.actividadRepository.find({ + where: { + programaId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: { sequence: 'ASC' }, + }); + + if (actividades.length === 0) { + return { + programaId: programa.id, + programaName: programa.name, + totalPlannedWeight: 0, + dataPoints: [], + currentVariance: 0, + status: 'on_track', + }; + } + + // Calculate total planned weight + const totalPlannedWeight = actividades.reduce( + (sum, a) => sum + Number(a.plannedWeight), + 0 + ); + + // Generate date series from programa start to today (or end date if past) + const today = new Date(); + const endDate = new Date(programa.endDate) < today ? new Date(programa.endDate) : today; + const dates = this.generateDateSeries(new Date(programa.startDate), endDate, periodType); + + // Calculate planned curve (based on actividad dates and weights) + const plannedCurve = this.calculatePlannedCurve(actividades, dates, totalPlannedWeight); + + // Calculate actual curve (based on avances) + const actualCurve = await this.calculateActualCurve(ctx, actividades, dates, totalPlannedWeight); + + // Build data points + const dataPoints: SCurveDataPoint[] = dates.map((date, index) => { + const planned = plannedCurve[index] || 0; + const actual = actualCurve[index] || 0; + return { + date, + plannedCumulative: planned, + actualCumulative: actual, + plannedPercent: totalPlannedWeight > 0 ? (planned / totalPlannedWeight) * 100 : 0, + actualPercent: totalPlannedWeight > 0 ? (actual / totalPlannedWeight) * 100 : 0, + variance: actual - planned, + }; + }); + + // Calculate current variance + const lastPoint = dataPoints[dataPoints.length - 1]; + const currentVariance = lastPoint ? lastPoint.actualPercent - lastPoint.plannedPercent : 0; + + // Determine status + let status: 'ahead' | 'on_track' | 'behind' | 'critical'; + if (currentVariance >= 5) { + status = 'ahead'; + } else if (currentVariance >= -5) { + status = 'on_track'; + } else if (currentVariance >= -15) { + status = 'behind'; + } else { + status = 'critical'; + } + + return { + programaId: programa.id, + programaName: programa.name, + totalPlannedWeight, + dataPoints, + currentVariance, + status, + }; + } + + // ============ PRIVATE HELPER METHODS ============ + + /** + * Generate programa code: PRG-YYYY-NNNNN + */ + private async generateProgramaCode(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const prefix = `PRG-${year}-`; + + // Find max number for this year + const lastPrograma = await this.programaRepository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.code LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('p.code', 'DESC') + .getOne(); + + let nextNumber = 1; + if (lastPrograma && lastPrograma.code) { + const lastNumber = parseInt(lastPrograma.code.replace(prefix, ''), 10); + if (!isNaN(lastNumber)) { + nextNumber = lastNumber + 1; + } + } + + return `${prefix}${String(nextNumber).padStart(5, '0')}`; + } + + /** + * Get max sequence for actividades at a level + */ + private async getMaxSequence( + ctx: ServiceContext, + programaId: string, + parentId: string | null + ): Promise { + const qb = this.actividadRepository + .createQueryBuilder('a') + .select('MAX(a.sequence)', 'maxSeq') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.programa_id = :programaId', { programaId }) + .andWhere('a.deleted_at IS NULL'); + + if (parentId) { + qb.andWhere('a.parent_id = :parentId', { parentId }); + } else { + qb.andWhere('a.parent_id IS NULL'); + } + + const result = await qb.getRawOne(); + return result?.maxSeq || 0; + } + + /** + * Generate WBS code based on hierarchy + */ + private async generateWbsCode( + ctx: ServiceContext, + programaId: string, + parentId: string | null + ): Promise { + if (!parentId) { + // Root level: count existing root actividades + const count = await this.actividadRepository.count({ + where: { + programaId, + tenantId: ctx.tenantId, + parentId: IsNull(), + deletedAt: IsNull(), + }, + }); + return String(count + 1); + } + + // Child level: get parent's WBS and append child number + const parent = await this.actividadRepository.findOne({ + where: { + id: parentId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + return '1'; + } + + const childCount = await this.actividadRepository.count({ + where: { + programaId, + tenantId: ctx.tenantId, + parentId, + deletedAt: IsNull(), + }, + }); + + const parentWbs = parent.wbsCode || '1'; + return `${parentWbs}.${childCount + 1}`; + } + + /** + * Check for circular reference in parent chain + */ + private async checkCircularReference( + actividadId: string, + newParentId: string + ): Promise { + let currentId: string | null = newParentId; + const visited = new Set(); + + while (currentId) { + if (currentId === actividadId) { + return true; + } + if (visited.has(currentId)) { + return true; + } + visited.add(currentId); + + const parent = await this.actividadRepository.findOne({ + where: { id: currentId }, + select: ['parentId'], + }); + + currentId = parent?.parentId ?? null; + } + + return false; + } + + /** + * Soft delete children of an actividad recursively + */ + private async softDeleteChildren(ctx: ServiceContext, parentId: string): Promise { + const children = await this.actividadRepository.find({ + where: { + parentId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + for (const child of children) { + child.deletedAt = new Date(); + child.deletedById = ctx.userId ?? null; + await this.actividadRepository.save(child); + await this.softDeleteChildren(ctx, child.id); + } + } + + /** + * Sort actividades by hierarchy (parents before children) + */ + private sortActividadesByHierarchy(actividades: ProgramaActividad[]): ProgramaActividad[] { + const sorted: ProgramaActividad[] = []; + + const addWithChildren = (parentId: string | null) => { + const items = actividades.filter((a) => a.parentId === parentId); + items.sort((a, b) => a.sequence - b.sequence); + for (const item of items) { + sorted.push(item); + addWithChildren(item.id); + } + }; + + addWithChildren(null); + + // Add any orphans (items whose parent is not in this list) + for (const a of actividades) { + if (!sorted.includes(a)) { + sorted.push(a); + } + } + + return sorted; + } + + /** + * Regenerate WBS codes for all actividades + */ + private async regenerateAllWbsCodes( + _ctx: ServiceContext, + _programaId: string, + actividades: ProgramaActividad[], + repo: Repository + ): Promise { + const wbsMap = new Map(); + + const generateWbs = (parentId: string | null, parentWbs: string | null): void => { + const children = actividades.filter((a) => a.parentId === parentId); + children.sort((a, b) => a.sequence - b.sequence); + + let counter = 1; + for (const child of children) { + const wbs = parentWbs ? `${parentWbs}.${counter}` : String(counter); + wbsMap.set(child.id, wbs); + generateWbs(child.id, wbs); + counter++; + } + }; + + generateWbs(null, null); + + // Update WBS codes + for (const actividad of actividades) { + const newWbs = wbsMap.get(actividad.id); + if (newWbs && newWbs !== actividad.wbsCode) { + await repo.update({ id: actividad.id }, { wbsCode: newWbs }); + } + } + } + + /** + * Calculate progress for actividades from avances + */ + private async calculateActividadesProgress( + ctx: ServiceContext, + actividades: ProgramaActividad[] + ): Promise> { + const progressMap = new Map(); + + // Get concepto IDs from actividades + const conceptoIds = actividades + .filter((a) => a.conceptoId) + .map((a) => a.conceptoId as string); + + if (conceptoIds.length === 0) { + return progressMap; + } + + // Query approved avances grouped by concepto + const avancesQuery = await this.avanceRepository + .createQueryBuilder('a') + .select('a.concepto_id', 'conceptoId') + .addSelect('SUM(a.quantity_executed)', 'totalQuantity') + .addSelect('AVG(a.percentage_executed)', 'avgProgress') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.concepto_id IN (:...conceptoIds)', { conceptoIds }) + .andWhere('a.status = :status', { status: 'approved' }) + .andWhere('a.deleted_at IS NULL') + .groupBy('a.concepto_id') + .getRawMany(); + + // Build concepto to progress map + const conceptoProgressMap = new Map(); + for (const row of avancesQuery) { + conceptoProgressMap.set(row.conceptoId, { + progress: Number(row.avgProgress) || 0, + quantity: Number(row.totalQuantity) || 0, + }); + } + + // Map to actividades + for (const actividad of actividades) { + if (actividad.conceptoId) { + const progress = conceptoProgressMap.get(actividad.conceptoId); + if (progress) { + progressMap.set(actividad.id, progress); + } + } + } + + return progressMap; + } + + /** + * Build hierarchical Gantt activity structure + */ + private buildGanttHierarchy( + actividades: ProgramaActividad[], + progressMap: Map + ): GanttActivity[] { + const buildNode = (actividad: ProgramaActividad): GanttActivity => { + const children = actividades + .filter((a) => a.parentId === actividad.id) + .sort((a, b) => a.sequence - b.sequence) + .map((child) => buildNode(child)); + + const progress = progressMap.get(actividad.id) || { progress: 0, quantity: 0 }; + + return { + id: actividad.id, + name: actividad.name, + wbsCode: actividad.wbsCode, + parentId: actividad.parentId, + conceptoId: actividad.conceptoId, + conceptoCode: actividad.concepto?.code ?? null, + plannedStart: actividad.plannedStart, + plannedEnd: actividad.plannedEnd, + plannedQuantity: Number(actividad.plannedQuantity), + plannedWeight: Number(actividad.plannedWeight), + actualProgress: progress.progress, + actualQuantity: progress.quantity, + sequence: actividad.sequence, + children, + }; + }; + + return actividades + .filter((a) => !a.parentId) + .sort((a, b) => a.sequence - b.sequence) + .map((a) => buildNode(a)); + } + + /** + * Calculate weighted average progress + */ + private calculateWeightedProgress( + actividades: ProgramaActividad[], + progressMap: Map + ): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const actividad of actividades) { + const weight = Number(actividad.plannedWeight); + const progress = progressMap.get(actividad.id)?.progress || 0; + + totalWeight += weight; + weightedSum += weight * progress; + } + + return totalWeight > 0 ? weightedSum / totalWeight : 0; + } + + /** + * Generate date series for S-curve + */ + private generateDateSeries( + from: Date, + to: Date, + periodType: 'daily' | 'weekly' | 'monthly' + ): Date[] { + const dates: Date[] = []; + const current = new Date(from); + + while (current <= to) { + dates.push(new Date(current)); + + switch (periodType) { + case 'daily': + current.setDate(current.getDate() + 1); + break; + case 'weekly': + current.setDate(current.getDate() + 7); + break; + case 'monthly': + current.setMonth(current.getMonth() + 1); + break; + } + } + + return dates; + } + + /** + * Calculate planned cumulative curve based on actividad dates + */ + private calculatePlannedCurve( + actividades: ProgramaActividad[], + dates: Date[], + _totalWeight: number + ): number[] { + const curve: number[] = []; + + for (const date of dates) { + let cumulativeWeight = 0; + + for (const actividad of actividades) { + if (!actividad.plannedStart || !actividad.plannedEnd) { + continue; + } + + const start = new Date(actividad.plannedStart); + const end = new Date(actividad.plannedEnd); + const weight = Number(actividad.plannedWeight); + + if (date < start) { + // Before activity starts: 0 weight + continue; + } else if (date >= end) { + // After activity ends: full weight + cumulativeWeight += weight; + } else { + // During activity: proportional weight + const totalDuration = end.getTime() - start.getTime(); + const elapsedDuration = date.getTime() - start.getTime(); + const proportion = totalDuration > 0 ? elapsedDuration / totalDuration : 0; + cumulativeWeight += weight * proportion; + } + } + + curve.push(cumulativeWeight); + } + + return curve; + } + + /** + * Calculate actual cumulative curve based on avances + */ + private async calculateActualCurve( + ctx: ServiceContext, + actividades: ProgramaActividad[], + dates: Date[], + _totalWeight: number + ): Promise { + const conceptoIds = actividades + .filter((a) => a.conceptoId) + .map((a) => a.conceptoId as string); + + if (conceptoIds.length === 0) { + return dates.map(() => 0); + } + + // Create concepto to weight map + const conceptoWeightMap = new Map(); + for (const actividad of actividades) { + if (actividad.conceptoId) { + const existing = conceptoWeightMap.get(actividad.conceptoId) || 0; + conceptoWeightMap.set(actividad.conceptoId, existing + Number(actividad.plannedWeight)); + } + } + + const curve: number[] = []; + + for (const date of dates) { + // Get approved avances up to this date + const avances = await this.avanceRepository + .createQueryBuilder('a') + .select('a.concepto_id', 'conceptoId') + .addSelect('AVG(a.percentage_executed)', 'avgProgress') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.concepto_id IN (:...conceptoIds)', { conceptoIds }) + .andWhere('a.status = :status', { status: 'approved' }) + .andWhere('a.capture_date <= :date', { date }) + .andWhere('a.deleted_at IS NULL') + .groupBy('a.concepto_id') + .getRawMany(); + + let cumulativeWeight = 0; + + for (const avance of avances) { + const weight = conceptoWeightMap.get(avance.conceptoId) || 0; + const progress = Number(avance.avgProgress) || 0; + cumulativeWeight += weight * (progress / 100); + } + + curve.push(cumulativeWeight); + } + + return curve; + } +}