/** * KPI Config Service * ERP Construccion - Modulo Reports (MAI-006) * * Logica de negocio para configuracion y calculo de KPIs dinamicos. * @gap GAP-001 */ import { Repository, DataSource } from 'typeorm'; import { KpiConfig, KpiCategory, FormulaType, CalculationFrequency } from '../entities/kpi-config.entity'; import { KpiValue, PeriodType, KpiStatus, TrendDirection } from '../entities/kpi-value.entity'; // DTOs export interface CreateKpiConfigDto { code: string; name: string; description?: string; category: KpiCategory; module: string; formula: string; formulaType?: FormulaType; queryFunction?: string; parametersSchema?: Record; unit?: string; decimalPlaces?: number; formatPattern?: string; targetValue?: number; thresholdGreen?: number; thresholdYellow?: number; invertColors?: boolean; calculationFrequency?: CalculationFrequency; displayOrder?: number; icon?: string; color?: string; isSystem?: boolean; } export interface UpdateKpiConfigDto extends Partial { isActive?: boolean; } export interface KpiConfigFilters { category?: KpiCategory; module?: string; isActive?: boolean; search?: string; } export interface CreateKpiValueDto { kpiId: string; periodStart: Date; periodEnd: Date; periodType?: PeriodType; projectId?: string; departmentId?: string; value: number; previousValue?: number; targetValue?: number; breakdown?: Record; } export interface KpiValueFilters { kpiId?: string; projectId?: string; periodType?: PeriodType; fromDate?: Date; toDate?: Date; } interface PaginationOptions { page: number; limit: number; } interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export class KpiConfigService { private configRepository: Repository; private valueRepository: Repository; constructor(dataSource: DataSource) { this.configRepository = dataSource.getRepository(KpiConfig); this.valueRepository = dataSource.getRepository(KpiValue); } // ============================================ // KPI CONFIG // ============================================ async create(tenantId: string, dto: CreateKpiConfigDto, userId?: string): Promise { const existing = await this.configRepository.findOne({ where: { tenantId, code: dto.code }, }); if (existing) { throw new Error(`KPI with code ${dto.code} already exists`); } const kpi = this.configRepository.create({ tenantId, ...dto, formulaType: dto.formulaType || 'sql', calculationFrequency: dto.calculationFrequency || 'daily', decimalPlaces: dto.decimalPlaces ?? 2, invertColors: dto.invertColors ?? false, isActive: true, isSystem: dto.isSystem ?? false, createdBy: userId, }); return this.configRepository.save(kpi); } async findById(tenantId: string, id: string): Promise { return this.configRepository.findOne({ where: { id, tenantId, deletedAt: undefined }, }); } async findByCode(tenantId: string, code: string): Promise { return this.configRepository.findOne({ where: { tenantId, code, deletedAt: undefined }, }); } async findAll( tenantId: string, filters: KpiConfigFilters = {}, pagination: PaginationOptions = { page: 1, limit: 20 } ): Promise> { const queryBuilder = this.configRepository.createQueryBuilder('kpi') .where('kpi.tenant_id = :tenantId', { tenantId }) .andWhere('kpi.deleted_at IS NULL'); if (filters.category) { queryBuilder.andWhere('kpi.category = :category', { category: filters.category }); } if (filters.module) { queryBuilder.andWhere('kpi.module = :module', { module: filters.module }); } if (filters.isActive !== undefined) { queryBuilder.andWhere('kpi.is_active = :isActive', { isActive: filters.isActive }); } if (filters.search) { queryBuilder.andWhere( '(kpi.code ILIKE :search OR kpi.name ILIKE :search)', { search: `%${filters.search}%` } ); } const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await queryBuilder .orderBy('kpi.display_order', 'ASC') .addOrderBy('kpi.name', 'ASC') .skip(skip) .take(pagination.limit) .getManyAndCount(); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } async update(tenantId: string, id: string, dto: UpdateKpiConfigDto, userId?: string): Promise { const kpi = await this.findById(tenantId, id); if (!kpi) return null; if (kpi.isSystem && !dto.isActive) { throw new Error('Cannot modify system KPIs'); } Object.assign(kpi, dto, { updatedBy: userId }); return this.configRepository.save(kpi); } async delete(tenantId: string, id: string, userId?: string): Promise { const kpi = await this.findById(tenantId, id); if (!kpi) return false; if (kpi.isSystem) { throw new Error('Cannot delete system KPIs'); } const result = await this.configRepository.update( { id, tenantId }, { deletedAt: new Date(), isActive: false, updatedBy: userId } ); return (result.affected ?? 0) > 0; } async getByCategory(tenantId: string, category: KpiCategory): Promise { return this.configRepository.find({ where: { tenantId, category, isActive: true, deletedAt: undefined }, order: { displayOrder: 'ASC', name: 'ASC' }, }); } async getByModule(tenantId: string, module: string): Promise { return this.configRepository.find({ where: { tenantId, module, isActive: true, deletedAt: undefined }, order: { displayOrder: 'ASC', name: 'ASC' }, }); } // ============================================ // KPI VALUES // ============================================ async recordValue(tenantId: string, dto: CreateKpiValueDto, _userId?: string): Promise { const kpi = await this.findById(tenantId, dto.kpiId); if (!kpi) { throw new Error('KPI configuration not found'); } // Calculate variance if target is provided let varianceValue: number | undefined; let variancePercentage: number | undefined; let status: KpiStatus | undefined; let isOnTarget: boolean | undefined; const targetValue = dto.targetValue ?? kpi.targetValue; if (targetValue !== undefined && targetValue !== null) { varianceValue = dto.value - targetValue; variancePercentage = targetValue !== 0 ? ((dto.value - targetValue) / targetValue) * 100 : 0; // Calculate status based on thresholds if (kpi.thresholdGreen !== undefined && kpi.thresholdYellow !== undefined) { if (kpi.invertColors) { status = dto.value <= kpi.thresholdGreen ? 'green' : dto.value <= kpi.thresholdYellow ? 'yellow' : 'red'; } else { status = dto.value >= kpi.thresholdGreen ? 'green' : dto.value >= kpi.thresholdYellow ? 'yellow' : 'red'; } isOnTarget = status === 'green'; } } // Calculate trend let trendDirection: TrendDirection | undefined; let changePercentage: number | undefined; if (dto.previousValue !== undefined && dto.previousValue !== null) { changePercentage = dto.previousValue !== 0 ? ((dto.value - dto.previousValue) / dto.previousValue) * 100 : 0; trendDirection = dto.value > dto.previousValue ? 'up' : dto.value < dto.previousValue ? 'down' : 'stable'; } const value = this.valueRepository.create({ tenantId, kpiId: dto.kpiId, periodStart: dto.periodStart, periodEnd: dto.periodEnd, periodType: dto.periodType || 'daily', projectId: dto.projectId, departmentId: dto.departmentId, value: dto.value, previousValue: dto.previousValue, targetValue, varianceValue, variancePercentage, status, isOnTarget, trendDirection, changePercentage, breakdown: dto.breakdown, calculatedAt: new Date(), }); return this.valueRepository.save(value); } async getValues( tenantId: string, filters: KpiValueFilters = {}, pagination: PaginationOptions = { page: 1, limit: 50 } ): Promise> { const queryBuilder = this.valueRepository.createQueryBuilder('val') .where('val.tenant_id = :tenantId', { tenantId }); if (filters.kpiId) { queryBuilder.andWhere('val.kpi_id = :kpiId', { kpiId: filters.kpiId }); } if (filters.projectId) { queryBuilder.andWhere('val.project_id = :projectId', { projectId: filters.projectId }); } if (filters.periodType) { queryBuilder.andWhere('val.period_type = :periodType', { periodType: filters.periodType }); } if (filters.fromDate) { queryBuilder.andWhere('val.period_start >= :fromDate', { fromDate: filters.fromDate }); } if (filters.toDate) { queryBuilder.andWhere('val.period_end <= :toDate', { toDate: filters.toDate }); } const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await queryBuilder .orderBy('val.period_start', 'DESC') .skip(skip) .take(pagination.limit) .getManyAndCount(); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } async getLatestValue(tenantId: string, kpiId: string, projectId?: string): Promise { const queryBuilder = this.valueRepository.createQueryBuilder('val') .where('val.tenant_id = :tenantId', { tenantId }) .andWhere('val.kpi_id = :kpiId', { kpiId }); if (projectId) { queryBuilder.andWhere('val.project_id = :projectId', { projectId }); } return queryBuilder .orderBy('val.period_start', 'DESC') .getOne(); } async getKpiDashboard(tenantId: string, projectId?: string): Promise> { const kpis = await this.configRepository.find({ where: { tenantId, isActive: true, deletedAt: undefined }, order: { displayOrder: 'ASC', name: 'ASC' }, }); return Promise.all( kpis.map(async (config) => ({ config, latestValue: await this.getLatestValue(tenantId, config.id, projectId), })) ); } }