[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:
parent
8275f03053
commit
ba1d239f21
259
src/modules/assets/controllers/depreciation.controller.ts
Normal file
259
src/modules/assets/controllers/depreciation.controller.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
245
src/modules/assets/controllers/tool-loan.controller.ts
Normal file
245
src/modules/assets/controllers/tool-loan.controller.ts
Normal 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;
|
||||
}
|
||||
423
src/modules/assets/services/depreciation.service.ts
Normal file
423
src/modules/assets/services/depreciation.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
327
src/modules/assets/services/tool-loan.service.ts
Normal file
327
src/modules/assets/services/tool-loan.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
262
src/modules/reports/controllers/kpi-config.controller.ts
Normal file
262
src/modules/reports/controllers/kpi-config.controller.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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';
|
||||
|
||||
351
src/modules/reports/services/kpi-config.service.ts
Normal file
351
src/modules/reports/services/kpi-config.service.ts
Normal 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),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user