[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 './asset.controller';
|
||||||
export * from './work-order.controller';
|
export * from './work-order.controller';
|
||||||
export * from './fuel-log.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 './asset.service';
|
||||||
export * from './work-order.service';
|
export * from './work-order.service';
|
||||||
export * from './fuel-log.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 { createDashboardController } from './dashboard.controller';
|
||||||
export { createKpiController } from './kpi.controller';
|
export { createKpiController } from './kpi.controller';
|
||||||
export { createEarnedValueController } from './earned-value.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';
|
} from 'typeorm';
|
||||||
import { Tenant } from '../../core/entities/tenant.entity';
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { KpiValue } from './kpi-value.entity';
|
import { KpiValue } from './kpi-value.entity';
|
||||||
|
import { KpiCategory } from './kpi-snapshot.entity';
|
||||||
|
|
||||||
export type KpiCategory =
|
export { KpiCategory };
|
||||||
| 'financial'
|
|
||||||
| 'progress'
|
|
||||||
| 'quality'
|
|
||||||
| 'hse'
|
|
||||||
| 'hr'
|
|
||||||
| 'inventory'
|
|
||||||
| 'operational';
|
|
||||||
|
|
||||||
export type FormulaType = 'sql' | 'expression' | 'function';
|
export type FormulaType = 'sql' | 'expression' | 'function';
|
||||||
|
|
||||||
|
|||||||
@ -19,13 +19,14 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Tenant } from '../../core/entities/tenant.entity';
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { KpiConfig } from './kpi-config.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 PeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||||
|
|
||||||
export type KpiStatus = 'green' | 'yellow' | 'red';
|
export type KpiStatus = 'green' | 'yellow' | 'red';
|
||||||
|
|
||||||
export type TrendDirection = 'up' | 'down' | 'stable';
|
|
||||||
|
|
||||||
@Entity({ schema: 'reports', name: 'kpis_values' })
|
@Entity({ schema: 'reports', name: 'kpis_values' })
|
||||||
@Index(['tenantId'])
|
@Index(['tenantId'])
|
||||||
@Index(['kpiId'])
|
@Index(['kpiId'])
|
||||||
|
|||||||
@ -7,3 +7,6 @@ export * from './report.service';
|
|||||||
export * from './dashboard.service';
|
export * from './dashboard.service';
|
||||||
export * from './kpi.service';
|
export * from './kpi.service';
|
||||||
export * from './earned-value.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