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>
352 lines
10 KiB
TypeScript
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),
|
|
}))
|
|
);
|
|
}
|
|
}
|