erp-construccion-backend-v2/src/modules/reports/services/kpi-config.service.ts
Adrian Flores Cortes ba1d239f21 [GAP-001,002,003] feat: Add services and controllers for 3 critical gaps
GAP-001: KPIs Configurables (5 SP)
- kpi-config.service.ts: Full CRUD, dashboard, values
- kpi-config.controller.ts: REST endpoints
- Updated kpi-config/value entities to reuse existing types

GAP-002: Tool Loans (3 SP)
- tool-loan.service.ts: CRUD, return, lost, approve operations
- tool-loan.controller.ts: REST endpoints

GAP-003: Depreciation (5 SP)
- depreciation.service.ts: Schedules, entries, calculations
- depreciation.controller.ts: REST endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:02:43 -06:00

352 lines
10 KiB
TypeScript

/**
* 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<string, any>;
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<CreateKpiConfigDto> {
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<string, any>;
}
export interface KpiValueFilters {
kpiId?: string;
projectId?: string;
periodType?: PeriodType;
fromDate?: Date;
toDate?: Date;
}
interface PaginationOptions {
page: number;
limit: number;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class KpiConfigService {
private configRepository: Repository<KpiConfig>;
private valueRepository: Repository<KpiValue>;
constructor(dataSource: DataSource) {
this.configRepository = dataSource.getRepository(KpiConfig);
this.valueRepository = dataSource.getRepository(KpiValue);
}
// ============================================
// KPI CONFIG
// ============================================
async create(tenantId: string, dto: CreateKpiConfigDto, userId?: string): Promise<KpiConfig> {
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<KpiConfig | null> {
return this.configRepository.findOne({
where: { id, tenantId, deletedAt: undefined },
});
}
async findByCode(tenantId: string, code: string): Promise<KpiConfig | null> {
return this.configRepository.findOne({
where: { tenantId, code, deletedAt: undefined },
});
}
async findAll(
tenantId: string,
filters: KpiConfigFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<KpiConfig>> {
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<KpiConfig | null> {
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<boolean> {
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<KpiConfig[]> {
return this.configRepository.find({
where: { tenantId, category, isActive: true, deletedAt: undefined },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async getByModule(tenantId: string, module: string): Promise<KpiConfig[]> {
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<KpiValue> {
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<PaginatedResult<KpiValue>> {
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<KpiValue | null> {
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<Array<{
config: KpiConfig;
latestValue: KpiValue | null;
}>> {
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),
}))
);
}
}