[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>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:02:43 -06:00
parent 8275f03053
commit ba1d239f21
12 changed files with 1890 additions and 10 deletions

View File

@ -0,0 +1,259 @@
/**
* DepreciationController - Controlador de Depreciacion
*
* Endpoints para gestion de depreciacion de activos fijos.
*
* @module Assets (MAE-015)
* @gap GAP-003
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { DepreciationService } from '../services';
export function createDepreciationController(dataSource: DataSource): Router {
const router = Router();
const service = new DepreciationService(dataSource);
// ==================== SCHEDULES ====================
/**
* GET /schedules
* Lista calendarios de depreciacion con filtros
*/
router.get('/schedules', async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters = {
assetType: req.query.assetType as any,
method: req.query.method as any,
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
isFullyDepreciated: req.query.isFullyDepreciated === 'true' ? true : req.query.isFullyDepreciated === 'false' ? false : undefined,
};
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await service.findAllSchedules(tenantId, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /summary
* Resumen de depreciacion
*/
router.get('/summary', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const summary = await service.getDepreciationSummary(tenantId);
res.json(summary);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /pending
* Lista depreciaciones pendientes
*/
router.get('/pending', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const asOfDate = req.query.asOfDate ? new Date(req.query.asOfDate as string) : new Date();
const schedules = await service.getPendingDepreciations(tenantId, asOfDate);
res.json(schedules);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /schedules/by-asset/:assetId
* Calendario de depreciacion por activo
*/
router.get('/schedules/by-asset/:assetId', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { assetId } = req.params;
const schedule = await service.findScheduleByAsset(tenantId, assetId);
if (!schedule) {
res.status(404).json({ error: 'Calendario de depreciacion no encontrado' });
return;
}
res.json(schedule);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /schedules/:id
* Obtiene calendario por ID
*/
router.get('/schedules/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const schedule = await service.findScheduleById(tenantId, id);
if (!schedule) {
res.status(404).json({ error: 'Calendario de depreciacion no encontrado' });
return;
}
res.json(schedule);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /schedules
* Crea un nuevo calendario de depreciacion
*/
router.post('/schedules', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const schedule = await service.createSchedule(tenantId, req.body, userId);
res.status(201).json(schedule);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /schedules/:id
* Actualiza un calendario
*/
router.put('/schedules/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const schedule = await service.updateSchedule(tenantId, id, req.body, userId);
if (!schedule) {
res.status(404).json({ error: 'Calendario de depreciacion no encontrado' });
return;
}
res.json(schedule);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== ENTRIES ====================
/**
* GET /schedules/:scheduleId/entries
* Lista entradas de depreciacion por calendario
*/
router.get('/schedules/:scheduleId/entries', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { scheduleId } = req.params;
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 50,
};
const result = await service.findEntriesBySchedule(tenantId, scheduleId, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /entries/:id
* Obtiene una entrada de depreciacion
*/
router.get('/entries/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const entry = await service.findEntryById(tenantId, id);
if (!entry) {
res.status(404).json({ error: 'Entrada de depreciacion no encontrada' });
return;
}
res.json(entry);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /entries
* Crea una entrada de depreciacion manual
*/
router.post('/entries', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const entry = await service.createEntry(tenantId, req.body, userId);
res.status(201).json(entry);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /schedules/:scheduleId/generate-entry
* Genera entrada de depreciacion mensual automatica
*/
router.post('/schedules/:scheduleId/generate-entry', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { scheduleId } = req.params;
const { periodDate, unitsUsed } = req.body;
const entry = await service.generateMonthlyEntry(
tenantId,
scheduleId,
new Date(periodDate),
unitsUsed,
userId
);
res.status(201).json(entry);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /entries/:id/post
* Contabiliza una entrada de depreciacion
*/
router.post('/entries/:id/post', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const { journalEntryId } = req.body;
const entry = await service.postEntry(tenantId, id, journalEntryId, userId);
if (!entry) {
res.status(404).json({ error: 'Entrada de depreciacion no encontrada' });
return;
}
res.json(entry);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -6,3 +6,9 @@
export * from './asset.controller';
export * from './work-order.controller';
export * from './fuel-log.controller';
// GAP-002: Prestamos de Herramientas
export * from './tool-loan.controller';
// GAP-003: Depreciacion
export * from './depreciation.controller';

View File

@ -0,0 +1,245 @@
/**
* ToolLoanController - Controlador de Prestamos de Herramientas
*
* Endpoints para gestion de prestamos de herramientas entre obras/empleados.
*
* @module Assets (MAE-015)
* @gap GAP-002
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ToolLoanService } from '../services';
export function createToolLoanController(dataSource: DataSource): Router {
const router = Router();
const service = new ToolLoanService(dataSource);
// ==================== CRUD ====================
/**
* GET /
* Lista prestamos con filtros y paginacion
*/
router.get('/', async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters = {
status: req.query.status as any,
employeeId: req.query.employeeId as string,
toolId: req.query.toolId as string,
fraccionamientoOrigenId: req.query.fraccionamientoOrigenId as string,
fraccionamientoDestinoId: req.query.fraccionamientoDestinoId as string,
overdue: req.query.overdue === 'true',
search: req.query.search as string,
};
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await service.findAll(tenantId, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /statistics
* Estadisticas de prestamos
*/
router.get('/statistics', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const stats = await service.getStatistics(tenantId);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /active
* Lista prestamos activos
*/
router.get('/active', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const loans = await service.getActiveLoans(tenantId);
res.json(loans);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /overdue
* Lista prestamos vencidos
*/
router.get('/overdue', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const loans = await service.getOverdueLoans(tenantId);
res.json(loans);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-employee/:employeeId
* Prestamos por empleado
*/
router.get('/by-employee/:employeeId', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { employeeId } = req.params;
const loans = await service.getLoansByEmployee(tenantId, employeeId);
res.json(loans);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-tool/:toolId
* Historial de prestamos por herramienta
*/
router.get('/by-tool/:toolId', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { toolId } = req.params;
const loans = await service.getLoansByTool(tenantId, toolId);
res.json(loans);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id
* Obtiene prestamo por ID
*/
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const loan = await service.findById(tenantId, id);
if (!loan) {
res.status(404).json({ error: 'Prestamo no encontrado' });
return;
}
res.json(loan);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /
* Crea un nuevo prestamo
*/
router.post('/', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const loan = await service.create(tenantId, req.body, userId);
res.status(201).json(loan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /:id
* Actualiza un prestamo
*/
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const loan = await service.update(tenantId, id, req.body, userId);
if (!loan) {
res.status(404).json({ error: 'Prestamo no encontrado' });
return;
}
res.json(loan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== OPERACIONES ====================
/**
* POST /:id/return
* Registra devolucion de herramienta
*/
router.post('/:id/return', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const loan = await service.returnTool(tenantId, id, req.body, userId);
if (!loan) {
res.status(404).json({ error: 'Prestamo no encontrado' });
return;
}
res.json(loan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/lost
* Marca herramienta como perdida
*/
router.post('/:id/lost', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const loan = await service.markAsLost(tenantId, id, req.body.notes, userId);
if (!loan) {
res.status(404).json({ error: 'Prestamo no encontrado' });
return;
}
res.json(loan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/approve
* Aprueba un prestamo
*/
router.post('/:id/approve', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const loan = await service.approveLoan(tenantId, id, userId);
if (!loan) {
res.status(404).json({ error: 'Prestamo no encontrado' });
return;
}
res.json(loan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,423 @@
/**
* Depreciation Service
* ERP Construccion - Modulo Assets (MAE-015)
*
* Logica de negocio para depreciacion de activos fijos.
* @gap GAP-003
*/
import { Repository, DataSource } from 'typeorm';
import { DepreciationSchedule, DepreciationMethod, AssetDepreciationType } from '../entities/depreciation-schedule.entity';
import { DepreciationEntry } from '../entities/depreciation-entry.entity';
import { Asset } from '../entities/asset.entity';
// DTOs
export interface CreateDepreciationScheduleDto {
assetId: string;
assetType: AssetDepreciationType;
method?: DepreciationMethod;
originalValue: number;
salvageValue?: number;
usefulLifeMonths: number;
usefulLifeUnits?: number;
depreciationStartDate: Date;
notes?: string;
}
export interface UpdateDepreciationScheduleDto {
method?: DepreciationMethod;
salvageValue?: number;
usefulLifeMonths?: number;
usefulLifeUnits?: number;
depreciationEndDate?: Date;
isActive?: boolean;
notes?: string;
}
export interface CreateDepreciationEntryDto {
scheduleId: string;
periodDate: Date;
depreciationAmount: number;
unitsUsed?: number;
notes?: string;
}
export interface DepreciationScheduleFilters {
assetType?: AssetDepreciationType;
method?: DepreciationMethod;
isActive?: boolean;
isFullyDepreciated?: boolean;
}
// Re-use shared interfaces
interface PaginationOptions {
page: number;
limit: number;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class DepreciationService {
private scheduleRepository: Repository<DepreciationSchedule>;
private entryRepository: Repository<DepreciationEntry>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.scheduleRepository = dataSource.getRepository(DepreciationSchedule);
this.entryRepository = dataSource.getRepository(DepreciationEntry);
this.assetRepository = dataSource.getRepository(Asset);
}
// ============================================
// DEPRECIATION SCHEDULES
// ============================================
async createSchedule(tenantId: string, dto: CreateDepreciationScheduleDto, userId?: string): Promise<DepreciationSchedule> {
// Verify asset exists
const asset = await this.assetRepository.findOne({
where: { id: dto.assetId, tenantId },
});
if (!asset) {
throw new Error('Asset not found');
}
// Check if asset already has a depreciation schedule
const existingSchedule = await this.scheduleRepository.findOne({
where: { tenantId, assetId: dto.assetId, isActive: true },
});
if (existingSchedule) {
throw new Error('Asset already has an active depreciation schedule');
}
const salvageValue = dto.salvageValue ?? 0;
const currentBookValue = dto.originalValue;
const schedule = this.scheduleRepository.create({
tenantId,
assetId: dto.assetId,
assetType: dto.assetType,
method: dto.method || 'straight_line',
originalValue: dto.originalValue,
salvageValue,
usefulLifeMonths: dto.usefulLifeMonths,
usefulLifeUnits: dto.usefulLifeUnits,
depreciationStartDate: dto.depreciationStartDate,
accumulatedDepreciation: 0,
currentBookValue,
isActive: true,
isFullyDepreciated: false,
notes: dto.notes,
createdBy: userId,
});
const savedSchedule = await this.scheduleRepository.save(schedule);
// Update asset depreciation info
await this.assetRepository.update(
{ id: dto.assetId, tenantId },
{
depreciationMethod: dto.method || 'straight_line',
usefulLifeYears: Math.ceil(dto.usefulLifeMonths / 12),
salvageValue,
currentBookValue,
accumulatedDepreciation: 0,
}
);
return savedSchedule;
}
async findScheduleById(tenantId: string, id: string): Promise<DepreciationSchedule | null> {
return this.scheduleRepository.findOne({
where: { id, tenantId },
relations: ['asset', 'entries'],
});
}
async findScheduleByAsset(tenantId: string, assetId: string): Promise<DepreciationSchedule | null> {
return this.scheduleRepository.findOne({
where: { tenantId, assetId, isActive: true },
relations: ['entries'],
});
}
async findAllSchedules(
tenantId: string,
filters: DepreciationScheduleFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<DepreciationSchedule>> {
const queryBuilder = this.scheduleRepository.createQueryBuilder('schedule')
.leftJoinAndSelect('schedule.asset', 'asset')
.where('schedule.tenant_id = :tenantId', { tenantId });
if (filters.assetType) {
queryBuilder.andWhere('schedule.asset_type = :assetType', { assetType: filters.assetType });
}
if (filters.method) {
queryBuilder.andWhere('schedule.method = :method', { method: filters.method });
}
if (filters.isActive !== undefined) {
queryBuilder.andWhere('schedule.is_active = :isActive', { isActive: filters.isActive });
}
if (filters.isFullyDepreciated !== undefined) {
queryBuilder.andWhere('schedule.is_fully_depreciated = :isFullyDepreciated', { isFullyDepreciated: filters.isFullyDepreciated });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('schedule.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
async updateSchedule(tenantId: string, id: string, dto: UpdateDepreciationScheduleDto, userId?: string): Promise<DepreciationSchedule | null> {
const schedule = await this.findScheduleById(tenantId, id);
if (!schedule) return null;
Object.assign(schedule, dto, { updatedBy: userId });
return this.scheduleRepository.save(schedule);
}
// ============================================
// DEPRECIATION ENTRIES
// ============================================
async createEntry(tenantId: string, dto: CreateDepreciationEntryDto, userId?: string): Promise<DepreciationEntry> {
const schedule = await this.findScheduleById(tenantId, dto.scheduleId);
if (!schedule) {
throw new Error('Depreciation schedule not found');
}
if (!schedule.isActive || schedule.isFullyDepreciated) {
throw new Error('Schedule is not active or already fully depreciated');
}
// Calculate new accumulated depreciation and book value
const accumulatedDepreciation = schedule.accumulatedDepreciation + dto.depreciationAmount;
const depreciableAmount = schedule.originalValue - schedule.salvageValue;
const bookValue = schedule.originalValue - accumulatedDepreciation;
// Check if exceeds depreciable amount
if (accumulatedDepreciation > depreciableAmount) {
throw new Error('Depreciation amount exceeds remaining depreciable value');
}
const periodDate = new Date(dto.periodDate);
const fiscalYear = periodDate.getFullYear();
const fiscalMonth = periodDate.getMonth() + 1;
const entry = this.entryRepository.create({
tenantId,
scheduleId: dto.scheduleId,
periodDate: dto.periodDate,
fiscalYear,
fiscalMonth,
depreciationAmount: dto.depreciationAmount,
accumulatedDepreciation,
bookValue,
unitsUsed: dto.unitsUsed,
status: 'draft',
notes: dto.notes,
createdBy: userId,
});
const savedEntry = await this.entryRepository.save(entry);
// Update schedule totals
const isFullyDepreciated = accumulatedDepreciation >= depreciableAmount;
await this.scheduleRepository.update(
{ id: dto.scheduleId, tenantId },
{
accumulatedDepreciation,
currentBookValue: bookValue,
lastEntryDate: dto.periodDate,
isFullyDepreciated,
updatedBy: userId,
}
);
// Update asset book value
await this.assetRepository.update(
{ id: schedule.assetId, tenantId },
{
currentBookValue: bookValue,
accumulatedDepreciation,
lastDepreciationDate: dto.periodDate,
}
);
return savedEntry;
}
async findEntryById(tenantId: string, id: string): Promise<DepreciationEntry | null> {
return this.entryRepository.findOne({
where: { id, tenantId },
relations: ['schedule'],
});
}
async findEntriesBySchedule(
tenantId: string,
scheduleId: string,
pagination: PaginationOptions = { page: 1, limit: 50 }
): Promise<PaginatedResult<DepreciationEntry>> {
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await this.entryRepository.findAndCount({
where: { tenantId, scheduleId },
order: { periodDate: 'DESC' },
skip,
take: pagination.limit,
});
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
async postEntry(tenantId: string, id: string, journalEntryId?: string, userId?: string): Promise<DepreciationEntry | null> {
const entry = await this.findEntryById(tenantId, id);
if (!entry) return null;
if (entry.status !== 'draft') {
throw new Error('Entry is not in draft status');
}
entry.status = 'posted';
entry.isPosted = true;
entry.postedAt = new Date();
entry.postedBy = userId;
entry.journalEntryId = journalEntryId;
return this.entryRepository.save(entry);
}
// ============================================
// CALCULATIONS
// ============================================
calculateMonthlyDepreciation(schedule: DepreciationSchedule): number {
const depreciableAmount = schedule.originalValue - schedule.salvageValue;
switch (schedule.method) {
case 'straight_line':
return depreciableAmount / schedule.usefulLifeMonths;
case 'declining_balance':
const remainingValue = schedule.originalValue - schedule.accumulatedDepreciation;
const rate = 1 / schedule.usefulLifeMonths;
return remainingValue * rate;
case 'double_declining':
const remainingValue2 = schedule.originalValue - schedule.accumulatedDepreciation;
const rate2 = (2 / schedule.usefulLifeMonths);
return Math.max(remainingValue2 * rate2, 0);
case 'sum_of_years':
const n = Math.ceil(schedule.usefulLifeMonths / 12);
const sumOfYears = (n * (n + 1)) / 2;
const currentYear = Math.ceil(
(new Date().getTime() - new Date(schedule.depreciationStartDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
) + 1;
const remainingYears = n - currentYear + 1;
return (depreciableAmount * remainingYears) / (sumOfYears * 12);
case 'units_of_production':
if (!schedule.usefulLifeUnits) return 0;
return depreciableAmount / schedule.usefulLifeUnits;
default:
return depreciableAmount / schedule.usefulLifeMonths;
}
}
async generateMonthlyEntry(tenantId: string, scheduleId: string, periodDate: Date, unitsUsed?: number, userId?: string): Promise<DepreciationEntry> {
const schedule = await this.findScheduleById(tenantId, scheduleId);
if (!schedule) {
throw new Error('Schedule not found');
}
let depreciationAmount = this.calculateMonthlyDepreciation(schedule);
// For units of production, calculate based on units used
if (schedule.method === 'units_of_production' && unitsUsed && schedule.usefulLifeUnits) {
const depreciableAmount = schedule.originalValue - schedule.salvageValue;
depreciationAmount = (depreciableAmount / schedule.usefulLifeUnits) * unitsUsed;
}
// Don't exceed remaining depreciable amount
const remainingDepreciable = (schedule.originalValue - schedule.salvageValue) - schedule.accumulatedDepreciation;
depreciationAmount = Math.min(depreciationAmount, remainingDepreciable);
return this.createEntry(tenantId, {
scheduleId,
periodDate,
depreciationAmount: Math.round(depreciationAmount * 100) / 100,
unitsUsed,
}, userId);
}
// ============================================
// REPORTS
// ============================================
async getDepreciationSummary(tenantId: string): Promise<{
totalOriginalValue: number;
totalAccumulatedDepreciation: number;
totalBookValue: number;
activeSchedules: number;
fullyDepreciated: number;
}> {
const result = await this.scheduleRepository
.createQueryBuilder('schedule')
.select('SUM(schedule.original_value)', 'totalOriginalValue')
.addSelect('SUM(schedule.accumulated_depreciation)', 'totalAccumulatedDepreciation')
.addSelect('SUM(schedule.current_book_value)', 'totalBookValue')
.addSelect('COUNT(CASE WHEN schedule.is_active = true THEN 1 END)', 'activeSchedules')
.addSelect('COUNT(CASE WHEN schedule.is_fully_depreciated = true THEN 1 END)', 'fullyDepreciated')
.where('schedule.tenant_id = :tenantId', { tenantId })
.getRawOne();
return {
totalOriginalValue: parseFloat(result.totalOriginalValue) || 0,
totalAccumulatedDepreciation: parseFloat(result.totalAccumulatedDepreciation) || 0,
totalBookValue: parseFloat(result.totalBookValue) || 0,
activeSchedules: parseInt(result.activeSchedules) || 0,
fullyDepreciated: parseInt(result.fullyDepreciated) || 0,
};
}
async getPendingDepreciations(tenantId: string, asOfDate: Date): Promise<DepreciationSchedule[]> {
return this.scheduleRepository
.createQueryBuilder('schedule')
.leftJoinAndSelect('schedule.asset', 'asset')
.where('schedule.tenant_id = :tenantId', { tenantId })
.andWhere('schedule.is_active = true')
.andWhere('schedule.is_fully_depreciated = false')
.andWhere('(schedule.last_entry_date IS NULL OR schedule.last_entry_date < :asOfDate)', { asOfDate })
.orderBy('schedule.depreciation_start_date', 'ASC')
.getMany();
}
}

View File

@ -6,3 +6,9 @@
export * from './asset.service';
export * from './work-order.service';
export * from './fuel-log.service';
// GAP-002: Préstamos de Herramientas
export * from './tool-loan.service';
// GAP-003: Depreciación
export * from './depreciation.service';

View File

@ -0,0 +1,327 @@
/**
* Tool Loan Service
* ERP Construccion - Modulo Assets (MAE-015)
*
* Logica de negocio para prestamos de herramientas entre obras/empleados.
* @gap GAP-002
*/
import { Repository, DataSource, LessThan } from 'typeorm';
import { ToolLoan, LoanStatus } from '../entities/tool-loan.entity';
import { Asset } from '../entities/asset.entity';
// DTOs
export interface CreateToolLoanDto {
toolId: string;
employeeId: string;
employeeName?: string;
fraccionamientoOrigenId?: string;
fraccionamientoOrigenName?: string;
fraccionamientoDestinoId?: string;
fraccionamientoDestinoName?: string;
loanDate: Date;
expectedReturnDate?: Date;
conditionOut?: string;
conditionOutPhotos?: string[];
notes?: string;
}
export interface UpdateToolLoanDto {
expectedReturnDate?: Date;
conditionOut?: string;
conditionOutPhotos?: string[];
notes?: string;
metadata?: Record<string, any>;
}
export interface ReturnToolDto {
actualReturnDate: Date;
conditionIn?: string;
conditionInPhotos?: string[];
status?: 'returned' | 'damaged';
notes?: string;
}
export interface ToolLoanFilters {
status?: LoanStatus;
employeeId?: string;
toolId?: string;
fraccionamientoOrigenId?: string;
fraccionamientoDestinoId?: string;
overdue?: boolean;
search?: string;
}
// Re-use shared interfaces
interface PaginationOptions {
page: number;
limit: number;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class ToolLoanService {
private loanRepository: Repository<ToolLoan>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.loanRepository = dataSource.getRepository(ToolLoan);
this.assetRepository = dataSource.getRepository(Asset);
}
// ============================================
// CRUD
// ============================================
async create(tenantId: string, dto: CreateToolLoanDto, userId?: string): Promise<ToolLoan> {
// Verify tool exists and is available
const tool = await this.assetRepository.findOne({
where: { id: dto.toolId, tenantId },
});
if (!tool) {
throw new Error('Tool not found');
}
if (tool.assetType !== 'tool') {
throw new Error('Asset is not a tool');
}
// Check if tool already has an active loan
const activeLoan = await this.loanRepository.findOne({
where: { tenantId, toolId: dto.toolId, status: 'active' },
});
if (activeLoan) {
throw new Error('Tool already has an active loan');
}
const loan = this.loanRepository.create({
tenantId,
...dto,
status: 'active',
createdBy: userId,
});
const savedLoan = await this.loanRepository.save(loan);
// Update tool status
await this.assetRepository.update(
{ id: dto.toolId, tenantId },
{ status: 'assigned', currentProjectId: dto.fraccionamientoDestinoId }
);
return savedLoan;
}
async findById(tenantId: string, id: string): Promise<ToolLoan | null> {
return this.loanRepository.findOne({
where: { id, tenantId },
relations: ['tool'],
});
}
async findAll(
tenantId: string,
filters: ToolLoanFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<ToolLoan>> {
const queryBuilder = this.loanRepository.createQueryBuilder('loan')
.leftJoinAndSelect('loan.tool', 'tool')
.where('loan.tenant_id = :tenantId', { tenantId });
if (filters.status) {
queryBuilder.andWhere('loan.status = :status', { status: filters.status });
}
if (filters.employeeId) {
queryBuilder.andWhere('loan.employee_id = :employeeId', { employeeId: filters.employeeId });
}
if (filters.toolId) {
queryBuilder.andWhere('loan.tool_id = :toolId', { toolId: filters.toolId });
}
if (filters.fraccionamientoOrigenId) {
queryBuilder.andWhere('loan.fraccionamiento_origen_id = :origen', { origen: filters.fraccionamientoOrigenId });
}
if (filters.fraccionamientoDestinoId) {
queryBuilder.andWhere('loan.fraccionamiento_destino_id = :destino', { destino: filters.fraccionamientoDestinoId });
}
if (filters.overdue) {
queryBuilder.andWhere('loan.status = :active', { active: 'active' });
queryBuilder.andWhere('loan.expected_return_date < :today', { today: new Date() });
}
if (filters.search) {
queryBuilder.andWhere(
'(loan.employee_name ILIKE :search OR tool.name ILIKE :search OR tool.asset_code ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('loan.loan_date', 'DESC')
.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: UpdateToolLoanDto, userId?: string): Promise<ToolLoan | null> {
const loan = await this.findById(tenantId, id);
if (!loan) return null;
if (loan.status !== 'active') {
throw new Error('Cannot update a closed loan');
}
Object.assign(loan, dto, { updatedBy: userId });
return this.loanRepository.save(loan);
}
// ============================================
// LOAN OPERATIONS
// ============================================
async returnTool(tenantId: string, id: string, dto: ReturnToolDto, userId?: string): Promise<ToolLoan | null> {
const loan = await this.findById(tenantId, id);
if (!loan) return null;
if (loan.status !== 'active') {
throw new Error('Loan is not active');
}
loan.actualReturnDate = dto.actualReturnDate;
loan.conditionIn = dto.conditionIn;
loan.conditionInPhotos = dto.conditionInPhotos;
loan.status = dto.status || 'returned';
loan.receivedById = userId;
loan.notes = dto.notes || loan.notes;
loan.updatedBy = userId;
const savedLoan = await this.loanRepository.save(loan);
// Update tool status back to available
await this.assetRepository.update(
{ id: loan.toolId, tenantId },
{
status: dto.status === 'damaged' ? 'in_maintenance' : 'available',
currentProjectId: undefined,
}
);
return savedLoan;
}
async markAsLost(tenantId: string, id: string, notes?: string, userId?: string): Promise<ToolLoan | null> {
const loan = await this.findById(tenantId, id);
if (!loan) return null;
if (loan.status !== 'active') {
throw new Error('Loan is not active');
}
loan.status = 'lost';
loan.notes = notes || loan.notes;
loan.updatedBy = userId;
const savedLoan = await this.loanRepository.save(loan);
// Update tool status to inactive
await this.assetRepository.update(
{ id: loan.toolId, tenantId },
{ status: 'inactive' }
);
return savedLoan;
}
async approveLoan(tenantId: string, id: string, userId: string): Promise<ToolLoan | null> {
const loan = await this.findById(tenantId, id);
if (!loan) return null;
loan.approvedById = userId;
loan.approvedAt = new Date();
loan.updatedBy = userId;
return this.loanRepository.save(loan);
}
// ============================================
// QUERIES
// ============================================
async getActiveLoans(tenantId: string): Promise<ToolLoan[]> {
return this.loanRepository.find({
where: { tenantId, status: 'active' },
relations: ['tool'],
order: { loanDate: 'DESC' },
});
}
async getOverdueLoans(tenantId: string): Promise<ToolLoan[]> {
const today = new Date();
return this.loanRepository.find({
where: {
tenantId,
status: 'active',
expectedReturnDate: LessThan(today),
},
relations: ['tool'],
order: { expectedReturnDate: 'ASC' },
});
}
async getLoansByEmployee(tenantId: string, employeeId: string): Promise<ToolLoan[]> {
return this.loanRepository.find({
where: { tenantId, employeeId },
relations: ['tool'],
order: { loanDate: 'DESC' },
});
}
async getLoansByTool(tenantId: string, toolId: string): Promise<ToolLoan[]> {
return this.loanRepository.find({
where: { tenantId, toolId },
order: { loanDate: 'DESC' },
});
}
async getStatistics(tenantId: string): Promise<{
active: number;
returned: number;
overdue: number;
lost: number;
damaged: number;
}> {
const today = new Date();
const [active, returned, overdue, lost, damaged] = await Promise.all([
this.loanRepository.count({ where: { tenantId, status: 'active' } }),
this.loanRepository.count({ where: { tenantId, status: 'returned' } }),
this.loanRepository.count({
where: {
tenantId,
status: 'active',
expectedReturnDate: LessThan(today),
}
}),
this.loanRepository.count({ where: { tenantId, status: 'lost' } }),
this.loanRepository.count({ where: { tenantId, status: 'damaged' } }),
]);
return { active, returned, overdue, lost, damaged };
}
}

View File

@ -7,3 +7,6 @@ export { createReportController } from './report.controller';
export { createDashboardController } from './dashboard.controller';
export { createKpiController } from './kpi.controller';
export { createEarnedValueController } from './earned-value.controller';
// GAP-001: KPIs Configurables
export { createKpiConfigController } from './kpi-config.controller';

View File

@ -0,0 +1,262 @@
/**
* KpiConfigController - Controlador de KPIs Configurables
*
* Endpoints para gestion de KPIs dinamicos con formulas configurables.
*
* @module Reports (MAI-006)
* @gap GAP-001
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { KpiConfigService } from '../services';
export function createKpiConfigController(dataSource: DataSource): Router {
const router = Router();
const service = new KpiConfigService(dataSource);
// ==================== KPI CONFIGS ====================
/**
* GET /
* Lista KPIs configurados con filtros
*/
router.get('/', async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters = {
category: req.query.category as any,
module: req.query.module as string,
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
search: req.query.search as string,
};
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await service.findAll(tenantId, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /dashboard
* Obtiene KPIs con sus ultimos valores para dashboard
*/
router.get('/dashboard', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const projectId = req.query.projectId as string | undefined;
const dashboard = await service.getKpiDashboard(tenantId, projectId);
res.json(dashboard);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-category/:category
* Lista KPIs por categoria
*/
router.get('/by-category/:category', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { category } = req.params;
const kpis = await service.getByCategory(tenantId, category as any);
res.json(kpis);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-module/:module
* Lista KPIs por modulo
*/
router.get('/by-module/:module', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { module } = req.params;
const kpis = await service.getByModule(tenantId, module);
res.json(kpis);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /code/:code
* Obtiene KPI por codigo
*/
router.get('/code/:code', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { code } = req.params;
const kpi = await service.findByCode(tenantId, code);
if (!kpi) {
res.status(404).json({ error: 'KPI no encontrado' });
return;
}
res.json(kpi);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id
* Obtiene KPI por ID
*/
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const kpi = await service.findById(tenantId, id);
if (!kpi) {
res.status(404).json({ error: 'KPI no encontrado' });
return;
}
res.json(kpi);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /
* Crea un nuevo KPI
*/
router.post('/', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const kpi = await service.create(tenantId, req.body, userId);
res.status(201).json(kpi);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /:id
* Actualiza un KPI
*/
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const kpi = await service.update(tenantId, id, req.body, userId);
if (!kpi) {
res.status(404).json({ error: 'KPI no encontrado' });
return;
}
res.json(kpi);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* DELETE /:id
* Elimina un KPI (soft delete)
*/
router.delete('/:id', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const { id } = req.params;
const deleted = await service.delete(tenantId, id, userId);
if (!deleted) {
res.status(404).json({ error: 'KPI no encontrado' });
return;
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== KPI VALUES ====================
/**
* GET /:id/values
* Lista valores historicos de un KPI
*/
router.get('/:id/values', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const filters = {
kpiId: id,
projectId: req.query.projectId as string | undefined,
periodType: req.query.periodType as any,
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 50,
};
const result = await service.getValues(tenantId, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id/latest
* Obtiene el ultimo valor de un KPI
*/
router.get('/:id/latest', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { id } = req.params;
const projectId = req.query.projectId as string | undefined;
const value = await service.getLatestValue(tenantId, id, projectId);
if (!value) {
res.status(404).json({ error: 'No hay valores registrados para este KPI' });
return;
}
res.json(value);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /values
* Registra un nuevo valor de KPI
*/
router.post('/values', async (req: Request, res: Response): Promise<void> => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const value = await service.recordValue(tenantId, req.body, userId);
res.status(201).json(value);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -22,15 +22,9 @@ import {
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { KpiValue } from './kpi-value.entity';
import { KpiCategory } from './kpi-snapshot.entity';
export type KpiCategory =
| 'financial'
| 'progress'
| 'quality'
| 'hse'
| 'hr'
| 'inventory'
| 'operational';
export { KpiCategory };
export type FormulaType = 'sql' | 'expression' | 'function';

View File

@ -19,13 +19,14 @@ import {
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { KpiConfig } from './kpi-config.entity';
import { TrendDirection } from './kpi-snapshot.entity';
export { TrendDirection };
export type PeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
export type KpiStatus = 'green' | 'yellow' | 'red';
export type TrendDirection = 'up' | 'down' | 'stable';
@Entity({ schema: 'reports', name: 'kpis_values' })
@Index(['tenantId'])
@Index(['kpiId'])

View File

@ -7,3 +7,6 @@ export * from './report.service';
export * from './dashboard.service';
export * from './kpi.service';
export * from './earned-value.service';
// GAP-001: KPIs Configurables
export * from './kpi-config.service';

View File

@ -0,0 +1,351 @@
/**
* 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),
}))
);
}
}