diff --git a/src/modules/assets/controllers/depreciation.controller.ts b/src/modules/assets/controllers/depreciation.controller.ts new file mode 100644 index 0000000..d982329 --- /dev/null +++ b/src/modules/assets/controllers/depreciation.controller.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +} diff --git a/src/modules/assets/controllers/index.ts b/src/modules/assets/controllers/index.ts index a5b4cd6..93b1db0 100644 --- a/src/modules/assets/controllers/index.ts +++ b/src/modules/assets/controllers/index.ts @@ -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'; diff --git a/src/modules/assets/controllers/tool-loan.controller.ts b/src/modules/assets/controllers/tool-loan.controller.ts new file mode 100644 index 0000000..4a51bca --- /dev/null +++ b/src/modules/assets/controllers/tool-loan.controller.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +} diff --git a/src/modules/assets/services/depreciation.service.ts b/src/modules/assets/services/depreciation.service.ts new file mode 100644 index 0000000..e68dc9e --- /dev/null +++ b/src/modules/assets/services/depreciation.service.ts @@ -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 { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class DepreciationService { + private scheduleRepository: Repository; + private entryRepository: Repository; + private assetRepository: Repository; + + 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 { + // 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 { + return this.scheduleRepository.findOne({ + where: { id, tenantId }, + relations: ['asset', 'entries'], + }); + } + + async findScheduleByAsset(tenantId: string, assetId: string): Promise { + return this.scheduleRepository.findOne({ + where: { tenantId, assetId, isActive: true }, + relations: ['entries'], + }); + } + + async findAllSchedules( + tenantId: string, + filters: DepreciationScheduleFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 20 } + ): Promise> { + 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 { + 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 { + 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 { + return this.entryRepository.findOne({ + where: { id, tenantId }, + relations: ['schedule'], + }); + } + + async findEntriesBySchedule( + tenantId: string, + scheduleId: string, + pagination: PaginationOptions = { page: 1, limit: 50 } + ): Promise> { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/modules/assets/services/index.ts b/src/modules/assets/services/index.ts index 268a963..bec1645 100644 --- a/src/modules/assets/services/index.ts +++ b/src/modules/assets/services/index.ts @@ -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'; diff --git a/src/modules/assets/services/tool-loan.service.ts b/src/modules/assets/services/tool-loan.service.ts new file mode 100644 index 0000000..fd876b6 --- /dev/null +++ b/src/modules/assets/services/tool-loan.service.ts @@ -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; +} + +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 { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class ToolLoanService { + private loanRepository: Repository; + private assetRepository: Repository; + + constructor(dataSource: DataSource) { + this.loanRepository = dataSource.getRepository(ToolLoan); + this.assetRepository = dataSource.getRepository(Asset); + } + + // ============================================ + // CRUD + // ============================================ + + async create(tenantId: string, dto: CreateToolLoanDto, userId?: string): Promise { + // 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 { + return this.loanRepository.findOne({ + where: { id, tenantId }, + relations: ['tool'], + }); + } + + async findAll( + tenantId: string, + filters: ToolLoanFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 20 } + ): Promise> { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.loanRepository.find({ + where: { tenantId, status: 'active' }, + relations: ['tool'], + order: { loanDate: 'DESC' }, + }); + } + + async getOverdueLoans(tenantId: string): Promise { + 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 { + return this.loanRepository.find({ + where: { tenantId, employeeId }, + relations: ['tool'], + order: { loanDate: 'DESC' }, + }); + } + + async getLoansByTool(tenantId: string, toolId: string): Promise { + 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 }; + } +} diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts index 42071d2..e697417 100644 --- a/src/modules/reports/controllers/index.ts +++ b/src/modules/reports/controllers/index.ts @@ -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'; diff --git a/src/modules/reports/controllers/kpi-config.controller.ts b/src/modules/reports/controllers/kpi-config.controller.ts new file mode 100644 index 0000000..571c223 --- /dev/null +++ b/src/modules/reports/controllers/kpi-config.controller.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +} diff --git a/src/modules/reports/entities/kpi-config.entity.ts b/src/modules/reports/entities/kpi-config.entity.ts index 89ed86b..a397993 100644 --- a/src/modules/reports/entities/kpi-config.entity.ts +++ b/src/modules/reports/entities/kpi-config.entity.ts @@ -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'; diff --git a/src/modules/reports/entities/kpi-value.entity.ts b/src/modules/reports/entities/kpi-value.entity.ts index d6cf561..ae38046 100644 --- a/src/modules/reports/entities/kpi-value.entity.ts +++ b/src/modules/reports/entities/kpi-value.entity.ts @@ -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']) diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts index 64f6928..fa09891 100644 --- a/src/modules/reports/services/index.ts +++ b/src/modules/reports/services/index.ts @@ -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'; diff --git a/src/modules/reports/services/kpi-config.service.ts b/src/modules/reports/services/kpi-config.service.ts new file mode 100644 index 0000000..226439f --- /dev/null +++ b/src/modules/reports/services/kpi-config.service.ts @@ -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; + 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), + })) + ); + } +}