[MAE-015] feat: Implement assets module backend

Complete implementation of the Assets/Machinery/Maintenance module:

Entities (10):
- AssetCategory: Hierarchical asset categorization with depreciation config
- Asset: Main asset entity (machinery, vehicles, tools, equipment)
- AssetAssignment: Asset-to-project assignments tracking
- WorkOrder: Maintenance work orders with workflow
- WorkOrderPart: Parts/materials used in work orders
- MaintenancePlan: Preventive maintenance plans
- MaintenanceHistory: Historical maintenance records
- FuelLog: Fuel consumption tracking with efficiency calculation
- AssetCost: TCO (Total Cost of Ownership) tracking

Services (3):
- AssetService: CRUD, assignments, categories, statistics
- WorkOrderService: CRUD, workflow (start/hold/resume/complete/cancel), parts
- FuelLogService: CRUD, efficiency calculation, statistics

Controllers (3):
- AssetController: REST API for assets, assignments, categories
- WorkOrderController: REST API for work orders, workflow, plans
- FuelLogController: REST API for fuel logs, statistics

Features:
- Multi-tenant support with tenant_id
- Complete workflow for work orders (draft→scheduled→in_progress→completed)
- Automatic efficiency calculation for fuel consumption
- Asset assignment history tracking
- Maintenance plan generation
- TCO tracking by cost type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 05:41:54 -06:00
parent bdf8c878e8
commit 5f9c30d268
19 changed files with 3806 additions and 0 deletions

View File

@ -0,0 +1,360 @@
/**
* AssetController - Controlador de Activos
*
* Endpoints para gestión de activos fijos, maquinaria y equipos.
*
* @module Assets (MAE-015)
*/
import { Router, Request, Response } from 'express';
import { DataSource } from 'typeorm';
import { AssetService } from '../services';
export function createAssetController(dataSource: DataSource): Router {
const router = Router();
const service = new AssetService(dataSource);
// ==================== CRUD DE ACTIVOS ====================
/**
* GET /
* Lista activos con filtros y paginación
*/
router.get('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const filters = {
assetType: req.query.assetType as any,
status: req.query.status as any,
ownershipType: req.query.ownershipType as any,
categoryId: req.query.categoryId as string,
projectId: req.query.projectId as string,
search: req.query.search as string,
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
};
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
* Obtiene estadísticas de activos
*/
router.get('/statistics', async (req: Request, res: Response) => {
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 /maintenance-due
* Lista activos que requieren mantenimiento
*/
router.get('/maintenance-due', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const assets = await service.getAssetsNeedingMaintenance(tenantId);
res.json(assets);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /search
* Búsqueda de activos para autocomplete
*/
router.get('/search', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const query = req.query.q as string;
const limit = parseInt(req.query.limit as string) || 10;
if (!query) {
return res.status(400).json({ error: 'Se requiere parámetro de búsqueda (q)' });
}
const assets = await service.search(tenantId, query, limit);
res.json(assets);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-code/:code
* Obtiene un activo por código
*/
router.get('/by-code/:code', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const asset = await service.findByCode(tenantId, req.params.code);
if (!asset) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id
* Obtiene un activo por ID
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const asset = await service.findById(tenantId, req.params.id);
if (!asset) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /
* Crea un nuevo activo
*/
router.post('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const asset = await service.create(tenantId, req.body, userId);
res.status(201).json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /:id
* Actualiza un activo
*/
router.put('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const asset = await service.update(tenantId, req.params.id, req.body, userId);
if (!asset) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PATCH /:id/status
* Actualiza el estado de un activo
*/
router.patch('/:id/status', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const { status } = req.body;
if (!status) {
return res.status(400).json({ error: 'Se requiere el nuevo estado' });
}
const asset = await service.updateStatus(tenantId, req.params.id, status, userId);
if (!asset) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PATCH /:id/usage
* Actualiza métricas de uso (horas/kilómetros)
*/
router.patch('/:id/usage', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const { hours, kilometers } = req.body;
const asset = await service.updateUsage(tenantId, req.params.id, hours, kilometers, userId);
if (!asset) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* DELETE /:id
* Elimina un activo (soft delete)
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const deleted = await service.delete(tenantId, req.params.id, userId);
if (!deleted) {
return res.status(404).json({ error: 'Activo no encontrado' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== ASIGNACIONES ====================
/**
* GET /:id/assignment
* Obtiene la asignación actual de un activo
*/
router.get('/:id/assignment', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const assignment = await service.getCurrentAssignment(tenantId, req.params.id);
res.json(assignment);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id/assignments
* Obtiene el historial de asignaciones de un activo
*/
router.get('/:id/assignments', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const pagination = {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const history = await service.getAssignmentHistory(tenantId, req.params.id, pagination);
res.json(history);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /:id/assign
* Asigna un activo a un proyecto
*/
router.post('/:id/assign', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const dto = {
...req.body,
assetId: req.params.id,
};
const assignment = await service.assignToProject(tenantId, dto, userId);
res.status(201).json(assignment);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/return
* Retorna un activo de un proyecto
*/
router.post('/:id/return', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const { endDate } = req.body;
const result = await service.returnFromProject(
tenantId,
req.params.id,
endDate ? new Date(endDate) : new Date(),
userId
);
if (!result) {
return res.status(400).json({ error: 'No hay asignación activa para este activo' });
}
res.json({ success: true, message: 'Activo retornado exitosamente' });
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== CATEGORÍAS ====================
/**
* GET /categories
* Lista categorías de activos
*/
router.get('/categories/list', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const categories = await service.getCategories(tenantId);
res.json(categories);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /categories
* Crea una nueva categoría
*/
router.post('/categories', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const category = await service.createCategory(tenantId, req.body, userId);
res.status(201).json(category);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,118 @@
/**
* FuelLogController - Controlador de Registro de Combustible
*
* Endpoints para gestión de cargas de combustible y rendimiento.
*
* @module Assets (MAE-015)
*/
import { Router, Request, Response } from 'express';
import { DataSource } from 'typeorm';
import { FuelLogService } from '../services';
export function createFuelLogController(dataSource: DataSource): Router {
const router = Router();
const service = new FuelLogService(dataSource);
/**
* GET /
* Lista registros de combustible con filtros
*/
router.get('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters = {
assetId: req.query.assetId as string,
projectId: req.query.projectId as string,
fuelType: req.query.fuelType as string,
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) || 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/:assetId
* Obtiene estadísticas de combustible para un activo
*/
router.get('/statistics/:assetId', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const fromDate = req.query.fromDate ? new Date(req.query.fromDate as string) : undefined;
const toDate = req.query.toDate ? new Date(req.query.toDate as string) : undefined;
const stats = await service.getAssetStatistics(tenantId, req.params.assetId, fromDate, toDate);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id
* Obtiene un registro por ID
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const fuelLog = await service.findById(tenantId, req.params.id);
if (!fuelLog) {
return res.status(404).json({ error: 'Registro de combustible no encontrado' });
}
res.json(fuelLog);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /
* Crea un nuevo registro de combustible
*/
router.post('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const fuelLog = await service.create(tenantId, req.body, userId);
res.status(201).json(fuelLog);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* DELETE /:id
* Elimina un registro de combustible
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const deleted = await service.delete(tenantId, req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Registro de combustible no encontrado' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,8 @@
/**
* Assets Controllers Index
* @module Assets (MAE-015)
*/
export * from './asset.controller';
export * from './work-order.controller';
export * from './fuel-log.controller';

View File

@ -0,0 +1,418 @@
/**
* WorkOrderController - Controlador de Órdenes de Trabajo
*
* Endpoints para gestión de órdenes de trabajo de mantenimiento.
*
* @module Assets (MAE-015)
*/
import { Router, Request, Response } from 'express';
import { DataSource } from 'typeorm';
import { WorkOrderService } from '../services';
export function createWorkOrderController(dataSource: DataSource): Router {
const router = Router();
const service = new WorkOrderService(dataSource);
// ==================== CRUD DE ÓRDENES ====================
/**
* GET /
* Lista órdenes de trabajo con filtros y paginación
*/
router.get('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters = {
assetId: req.query.assetId as string,
status: req.query.status as any,
priority: req.query.priority as any,
maintenanceType: req.query.maintenanceType as any,
technicianId: req.query.technicianId as string,
projectId: req.query.projectId as string,
dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined,
dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : 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 /statistics
* Obtiene estadísticas de órdenes de trabajo
*/
router.get('/statistics', async (req: Request, res: Response) => {
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 /by-status
* Obtiene órdenes agrupadas por estado
*/
router.get('/by-status', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const grouped = await service.getByStatus(tenantId);
res.json(grouped);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /by-number/:orderNumber
* Obtiene una orden por número
*/
router.get('/by-number/:orderNumber', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const workOrder = await service.findByNumber(tenantId, req.params.orderNumber);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /overdue
* Lista órdenes vencidas
*/
router.get('/overdue', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const orders = await service.getOverdue(tenantId);
res.json(orders);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /:id
* Obtiene una orden de trabajo por ID
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const workOrder = await service.findById(tenantId, req.params.id);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /
* Crea una nueva orden de trabajo
*/
router.post('/', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const workOrder = await service.create(tenantId, req.body, userId);
res.status(201).json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /:id
* Actualiza una orden de trabajo
*/
router.put('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const workOrder = await service.update(tenantId, req.params.id, req.body, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* DELETE /:id
* Elimina una orden de trabajo (soft delete)
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const deleted = await service.delete(tenantId, req.params.id, userId);
if (!deleted) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== WORKFLOW ====================
/**
* POST /:id/start
* Inicia una orden de trabajo
*/
router.post('/:id/start', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const workOrder = await service.start(tenantId, req.params.id, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/hold
* Pone en espera una orden de trabajo
*/
router.post('/:id/hold', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const { reason } = req.body;
const workOrder = await service.hold(tenantId, req.params.id, reason, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/resume
* Reanuda una orden de trabajo en espera
*/
router.post('/:id/resume', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const workOrder = await service.resume(tenantId, req.params.id, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/complete
* Completa una orden de trabajo
*/
router.post('/:id/complete', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const workOrder = await service.complete(tenantId, req.params.id, req.body, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /:id/cancel
* Cancela una orden de trabajo
*/
router.post('/:id/cancel', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const { reason } = req.body;
const workOrder = await service.cancel(tenantId, req.params.id, reason, userId);
if (!workOrder) {
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
}
res.json(workOrder);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== PARTES/REFACCIONES ====================
/**
* GET /:id/parts
* Lista partes usadas en una orden de trabajo
*/
router.get('/:id/parts', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const parts = await service.getParts(tenantId, req.params.id);
res.json(parts);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /:id/parts
* Agrega una parte a la orden de trabajo
*/
router.post('/:id/parts', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const part = await service.addPart(tenantId, req.params.id, req.body, userId);
res.status(201).json(part);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* PUT /:id/parts/:partId
* Actualiza una parte de la orden
*/
router.put('/:id/parts/:partId', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const part = await service.updatePart(tenantId, req.params.partId, req.body, userId);
if (!part) {
return res.status(404).json({ error: 'Parte no encontrada' });
}
res.json(part);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* DELETE /:id/parts/:partId
* Elimina una parte de la orden
*/
router.delete('/:id/parts/:partId', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const deleted = await service.removePart(tenantId, req.params.partId, userId);
if (!deleted) {
return res.status(404).json({ error: 'Parte no encontrada' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== PLANES DE MANTENIMIENTO ====================
/**
* GET /plans
* Lista planes de mantenimiento
*/
router.get('/plans/list', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const assetId = req.query.assetId as string;
const plans = await service.getMaintenancePlans(tenantId, assetId);
res.json(plans);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /plans
* Crea un plan de mantenimiento
*/
router.post('/plans', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const plan = await service.createMaintenancePlan(tenantId, req.body, userId);
res.status(201).json(plan);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* POST /plans/:planId/generate
* Genera órdenes de trabajo desde un plan
*/
router.post('/plans/:planId/generate', async (req: Request, res: Response) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = (req as any).user?.id;
const orders = await service.generateFromPlan(tenantId, req.params.planId, userId);
res.status(201).json(orders);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,134 @@
/**
* AssetAssignment Entity - Asignaciones de Activos a Proyectos
*
* Registro de movimientos de activos entre obras/proyectos.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
@Entity('asset_assignments', { schema: 'assets' })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'projectId'])
@Index(['tenantId', 'isCurrent'])
export class AssetAssignment {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Activo y proyecto
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@ManyToOne(() => Asset, (asset) => asset.assignments)
@JoinColumn({ name: 'asset_id' })
asset!: Asset;
@Column({ name: 'project_id', type: 'uuid' })
projectId!: string;
@Column({ name: 'project_code', length: 50, nullable: true })
projectCode?: string;
@Column({ name: 'project_name', length: 255, nullable: true })
projectName?: string;
// Periodo de asignacion
@Column({ name: 'start_date', type: 'date' })
startDate!: Date;
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate?: Date;
@Column({ name: 'is_current', type: 'boolean', default: true })
isCurrent!: boolean;
// Ubicacion especifica en obra
@Column({ name: 'location_in_project', length: 255, nullable: true })
locationInProject?: string;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude?: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude?: number;
// Operador asignado
@Column({ name: 'operator_id', type: 'uuid', nullable: true })
operatorId?: string;
@Column({ name: 'operator_name', length: 255, nullable: true })
operatorName?: string;
// Responsable
@Column({ name: 'responsible_id', type: 'uuid', nullable: true })
responsibleId?: string;
@Column({ name: 'responsible_name', length: 255, nullable: true })
responsibleName?: string;
// Metricas al inicio/fin
@Column({ name: 'hours_at_start', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursAtStart?: number;
@Column({ name: 'hours_at_end', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursAtEnd?: number;
@Column({ name: 'kilometers_at_start', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersAtStart?: number;
@Column({ name: 'kilometers_at_end', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersAtEnd?: number;
// Tarifas
@Column({ name: 'daily_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
dailyRate?: number;
@Column({ name: 'hourly_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
hourlyRate?: number;
// Razon de transferencia
@Column({ name: 'transfer_reason', type: 'text', nullable: true })
transferReason?: string;
@Column({ name: 'transfer_notes', type: 'text', nullable: true })
transferNotes?: string;
// Documento de entrega
@Column({ name: 'delivery_document_url', length: 500, nullable: true })
deliveryDocumentUrl?: string;
@Column({ name: 'return_document_url', length: 500, nullable: true })
returnDocumentUrl?: string;
// Metadatos
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,95 @@
/**
* AssetCategory Entity - Categorias de Activos
*
* Clasificacion jerarquica de activos con configuracion de depreciacion.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
@Entity('asset_categories', { schema: 'assets' })
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'parentId'])
export class AssetCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Informacion basica
@Column({ length: 20 })
code!: string;
@Column({ length: 100 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
// Jerarquia
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId?: string;
@ManyToOne(() => AssetCategory, (cat) => cat.children, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent?: AssetCategory;
@OneToMany(() => AssetCategory, (cat) => cat.parent)
children?: AssetCategory[];
@Column({ type: 'int', default: 1 })
level!: number;
// Configuracion de depreciacion
@Column({ name: 'useful_life_years', type: 'int', nullable: true })
usefulLifeYears?: number;
@Column({ name: 'depreciation_method', length: 50, nullable: true })
depreciationMethod?: string; // straight_line, declining_balance, units_of_production
@Column({
name: 'salvage_value_percentage',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
salvageValuePercentage?: number;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
// Metadatos
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,132 @@
/**
* AssetCost Entity - Costos de Activos (TCO)
*
* Registro de costos para calculo de TCO (Total Cost of Ownership).
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
export type CostType =
| 'maintenance'
| 'repair'
| 'fuel'
| 'insurance'
| 'tax'
| 'depreciation'
| 'operator'
| 'other';
@Entity('asset_costs', { schema: 'assets' })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'periodStart', 'periodEnd'])
@Index(['tenantId', 'costType'])
@Index(['tenantId', 'projectId'])
export class AssetCost {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Activo
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@ManyToOne(() => Asset)
@JoinColumn({ name: 'asset_id' })
asset!: Asset;
// Periodo
@Column({ name: 'period_start', type: 'date' })
periodStart!: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd!: Date;
@Column({ name: 'fiscal_year', type: 'int' })
fiscalYear!: number;
@Column({ name: 'fiscal_month', type: 'int' })
fiscalMonth!: number;
// Proyecto (si aplica)
@Column({ name: 'project_id', type: 'uuid', nullable: true })
projectId?: string;
@Column({ name: 'project_code', length: 50, nullable: true })
projectCode?: string;
// Tipo de costo
@Column({
name: 'cost_type',
type: 'enum',
enum: ['maintenance', 'repair', 'fuel', 'insurance', 'tax', 'depreciation', 'operator', 'other'],
enumName: 'cost_type',
})
costType!: CostType;
// Descripcion
@Column({ length: 255, nullable: true })
description?: string;
@Column({ name: 'reference_document', length: 100, nullable: true })
referenceDocument?: string;
// Monto
@Column({ type: 'decimal', precision: 18, scale: 2 })
amount!: number;
@Column({ length: 3, default: 'MXN' })
currency!: string;
// Uso asociado
@Column({ name: 'hours_in_period', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursInPeriod?: number;
@Column({ name: 'kilometers_in_period', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersInPeriod?: number;
// Calculo de tarifa
@Column({ name: 'cost_per_hour', type: 'decimal', precision: 18, scale: 4, nullable: true })
costPerHour?: number;
@Column({ name: 'cost_per_kilometer', type: 'decimal', precision: 18, scale: 4, nullable: true })
costPerKilometer?: number;
// Origen
@Column({ name: 'source_module', length: 50, nullable: true })
sourceModule?: string;
@Column({ name: 'source_id', type: 'uuid', nullable: true })
sourceId?: string;
// Notas
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,284 @@
/**
* Asset Entity - Catalogo Principal de Activos
*
* Maquinaria, equipo, vehiculos y herramientas de construccion.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { AssetCategory } from './asset-category.entity';
import { AssetAssignment } from './asset-assignment.entity';
import { MaintenanceHistory } from './maintenance-history.entity';
import { FuelLog } from './fuel-log.entity';
export type AssetType =
| 'heavy_machinery'
| 'light_equipment'
| 'vehicle'
| 'tool'
| 'computer'
| 'furniture'
| 'other';
export type AssetStatus =
| 'available'
| 'assigned'
| 'in_maintenance'
| 'in_transit'
| 'inactive'
| 'retired'
| 'sold';
export type OwnershipType = 'owned' | 'leased' | 'rented' | 'borrowed';
@Entity('assets', { schema: 'assets' })
@Index(['tenantId', 'assetCode'], { unique: true })
@Index(['tenantId', 'assetType'])
@Index(['tenantId', 'status'])
@Index(['tenantId', 'categoryId'])
@Index(['tenantId', 'currentProjectId'])
@Index(['tenantId', 'serialNumber'])
export class Asset {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Identificacion
@Column({ name: 'asset_code', length: 50 })
assetCode!: string;
@Column({ length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
// Clasificacion
@Column({ name: 'category_id', type: 'uuid', nullable: true })
categoryId?: string;
@ManyToOne(() => AssetCategory, { nullable: true })
@JoinColumn({ name: 'category_id' })
category?: AssetCategory;
@Column({
name: 'asset_type',
type: 'enum',
enum: ['heavy_machinery', 'light_equipment', 'vehicle', 'tool', 'computer', 'furniture', 'other'],
enumName: 'asset_type',
})
assetType!: AssetType;
@Column({
type: 'enum',
enum: ['available', 'assigned', 'in_maintenance', 'in_transit', 'inactive', 'retired', 'sold'],
enumName: 'asset_status',
default: 'available',
})
status!: AssetStatus;
@Column({
name: 'ownership_type',
type: 'enum',
enum: ['owned', 'leased', 'rented', 'borrowed'],
enumName: 'ownership_type',
default: 'owned',
})
ownershipType!: OwnershipType;
// Especificaciones tecnicas
@Column({ length: 100, nullable: true })
brand?: string;
@Column({ length: 100, nullable: true })
model?: string;
@Column({ name: 'serial_number', length: 100, nullable: true })
serialNumber?: string;
@Column({ name: 'year_manufactured', type: 'int', nullable: true })
yearManufactured?: number;
@Column({ type: 'jsonb', nullable: true })
specifications?: Record<string, any>;
// Capacidades
@Column({ length: 100, nullable: true })
capacity?: string;
@Column({ name: 'power_rating', length: 50, nullable: true })
powerRating?: string;
@Column({ name: 'fuel_type', length: 50, nullable: true })
fuelType?: string;
@Column({ name: 'fuel_capacity', type: 'decimal', precision: 10, scale: 2, nullable: true })
fuelCapacity?: number;
@Column({ name: 'fuel_consumption_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
fuelConsumptionRate?: number;
// Metricas de uso
@Column({ name: 'current_hours', type: 'decimal', precision: 12, scale: 2, default: 0 })
currentHours!: number;
@Column({ name: 'current_kilometers', type: 'decimal', precision: 12, scale: 2, default: 0 })
currentKilometers!: number;
@Column({ name: 'last_usage_update', type: 'timestamptz', nullable: true })
lastUsageUpdate?: Date;
// Ubicacion actual
@Column({ name: 'current_project_id', type: 'uuid', nullable: true })
currentProjectId?: string;
@Column({ name: 'current_location_name', length: 255, nullable: true })
currentLocationName?: string;
@Column({ name: 'current_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
currentLatitude?: number;
@Column({ name: 'current_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
currentLongitude?: number;
@Column({ name: 'last_location_update', type: 'timestamptz', nullable: true })
lastLocationUpdate?: Date;
// Informacion financiera
@Column({ name: 'purchase_date', type: 'date', nullable: true })
purchaseDate?: Date;
@Column({ name: 'purchase_price', type: 'decimal', precision: 18, scale: 2, nullable: true })
purchasePrice?: number;
@Column({ name: 'purchase_currency', length: 3, default: 'MXN' })
purchaseCurrency!: string;
@Column({ name: 'supplier_id', type: 'uuid', nullable: true })
supplierId?: string;
@Column({ name: 'invoice_number', length: 100, nullable: true })
invoiceNumber?: string;
// Depreciacion
@Column({ name: 'useful_life_years', type: 'int', nullable: true })
usefulLifeYears?: number;
@Column({ name: 'salvage_value', type: 'decimal', precision: 18, scale: 2, nullable: true })
salvageValue?: number;
@Column({ name: 'current_book_value', type: 'decimal', precision: 18, scale: 2, nullable: true })
currentBookValue?: number;
@Column({ name: 'accumulated_depreciation', type: 'decimal', precision: 18, scale: 2, default: 0 })
accumulatedDepreciation!: number;
@Column({ name: 'depreciation_method', length: 50, nullable: true })
depreciationMethod?: string;
@Column({ name: 'last_depreciation_date', type: 'date', nullable: true })
lastDepreciationDate?: Date;
// Arrendamiento (si aplica)
@Column({ name: 'lease_start_date', type: 'date', nullable: true })
leaseStartDate?: Date;
@Column({ name: 'lease_end_date', type: 'date', nullable: true })
leaseEndDate?: Date;
@Column({ name: 'lease_monthly_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
leaseMonthlyRate?: number;
@Column({ name: 'lease_contract_number', length: 100, nullable: true })
leaseContractNumber?: string;
@Column({ name: 'lessor_name', length: 255, nullable: true })
lessorName?: string;
// Seguro
@Column({ name: 'insurance_policy_number', length: 100, nullable: true })
insurancePolicyNumber?: string;
@Column({ name: 'insurance_company', length: 255, nullable: true })
insuranceCompany?: string;
@Column({ name: 'insurance_expiry_date', type: 'date', nullable: true })
insuranceExpiryDate?: Date;
@Column({ name: 'insurance_coverage_amount', type: 'decimal', precision: 18, scale: 2, nullable: true })
insuranceCoverageAmount?: number;
// Documentos
@Column({ name: 'photo_url', length: 500, nullable: true })
photoUrl?: string;
@Column({ name: 'manual_url', length: 500, nullable: true })
manualUrl?: string;
@Column({ name: 'registration_document_url', length: 500, nullable: true })
registrationDocumentUrl?: string;
// Proximo mantenimiento
@Column({ name: 'next_maintenance_date', type: 'date', nullable: true })
nextMaintenanceDate?: Date;
@Column({ name: 'next_maintenance_hours', type: 'decimal', precision: 12, scale: 2, nullable: true })
nextMaintenanceHours?: number;
@Column({ name: 'next_maintenance_kilometers', type: 'decimal', precision: 12, scale: 2, nullable: true })
nextMaintenanceKilometers?: number;
// Operador asignado
@Column({ name: 'assigned_operator_id', type: 'uuid', nullable: true })
assignedOperatorId?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'varchar', array: true, nullable: true })
tags?: string[];
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Relaciones
@OneToMany(() => AssetAssignment, (assignment) => assignment.asset)
assignments?: AssetAssignment[];
@OneToMany(() => MaintenanceHistory, (history) => history.asset)
maintenanceHistory?: MaintenanceHistory[];
@OneToMany(() => FuelLog, (log) => log.asset)
fuelLogs?: FuelLog[];
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,113 @@
/**
* FuelLog Entity - Registro de Combustible
*
* Cargas de combustible y calculo de rendimiento.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
@Entity('fuel_logs', { schema: 'assets' })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'logDate'])
export class FuelLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Activo
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@ManyToOne(() => Asset, (asset) => asset.fuelLogs)
@JoinColumn({ name: 'asset_id' })
asset!: Asset;
// Fecha y ubicacion
@Column({ name: 'log_date', type: 'date' })
logDate!: Date;
@Column({ name: 'log_time', type: 'time', nullable: true })
logTime?: string;
@Column({ name: 'project_id', type: 'uuid', nullable: true })
projectId?: string;
@Column({ length: 255, nullable: true })
location?: string;
// Combustible
@Column({ name: 'fuel_type', length: 50 })
fuelType!: string;
@Column({ name: 'quantity_liters', type: 'decimal', precision: 10, scale: 2 })
quantityLiters!: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 18, scale: 4 })
unitPrice!: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2 })
totalCost!: number;
// Metricas al cargar
@Column({ name: 'odometer_reading', type: 'decimal', precision: 12, scale: 2, nullable: true })
odometerReading?: number;
@Column({ name: 'hours_reading', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursReading?: number;
// Rendimiento calculado
@Column({ name: 'kilometers_since_last', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersSinceLast?: number;
@Column({ name: 'hours_since_last', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursSinceLast?: number;
@Column({ name: 'liters_per_100km', type: 'decimal', precision: 8, scale: 2, nullable: true })
litersPer100km?: number;
@Column({ name: 'liters_per_hour', type: 'decimal', precision: 8, scale: 2, nullable: true })
litersPerHour?: number;
// Proveedor
@Column({ name: 'vendor_name', length: 255, nullable: true })
vendorName?: string;
@Column({ name: 'invoice_number', length: 100, nullable: true })
invoiceNumber?: string;
// Operador
@Column({ name: 'operator_id', type: 'uuid', nullable: true })
operatorId?: string;
@Column({ name: 'operator_name', length: 255, nullable: true })
operatorName?: string;
// Notas
@Column({ type: 'text', nullable: true })
notes?: string;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,14 @@
/**
* Assets Entities Index
* @module Assets (MAE-015)
*/
export * from './asset-category.entity';
export * from './asset.entity';
export * from './asset-assignment.entity';
export * from './maintenance-plan.entity';
export * from './maintenance-history.entity';
export * from './work-order.entity';
export * from './work-order-part.entity';
export * from './asset-cost.entity';
export * from './fuel-log.entity';

View File

@ -0,0 +1,108 @@
/**
* MaintenanceHistory Entity - Historial de Mantenimientos
*
* Registro historico de todos los mantenimientos realizados.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
import { WorkOrder } from './work-order.entity';
@Entity('maintenance_history', { schema: 'assets' })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'maintenanceDate'])
export class MaintenanceHistory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Activo
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@ManyToOne(() => Asset, (asset) => asset.maintenanceHistory)
@JoinColumn({ name: 'asset_id' })
asset!: Asset;
// Orden de trabajo (si aplica)
@Column({ name: 'work_order_id', type: 'uuid', nullable: true })
workOrderId?: string;
@ManyToOne(() => WorkOrder, { nullable: true })
@JoinColumn({ name: 'work_order_id' })
workOrder?: WorkOrder;
// Fecha y tipo
@Column({ name: 'maintenance_date', type: 'date' })
maintenanceDate!: Date;
@Column({
name: 'maintenance_type',
type: 'enum',
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
enumName: 'maintenance_type',
})
maintenanceType!: string;
// Descripcion
@Column({ type: 'text' })
description!: string;
@Column({ name: 'work_performed', type: 'text', nullable: true })
workPerformed?: string;
// Metricas al momento del mantenimiento
@Column({ name: 'hours_at_maintenance', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursAtMaintenance?: number;
@Column({ name: 'kilometers_at_maintenance', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersAtMaintenance?: number;
// Costos
@Column({ name: 'labor_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
laborCost!: number;
@Column({ name: 'parts_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
partsCost!: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
totalCost!: number;
// Ejecutor
@Column({ name: 'performed_by_id', type: 'uuid', nullable: true })
performedById?: string;
@Column({ name: 'performed_by_name', length: 255, nullable: true })
performedByName?: string;
@Column({ name: 'vendor_name', length: 255, nullable: true })
vendorName?: string;
// Documentos
@Column({ type: 'jsonb', nullable: true })
documents?: string[];
// Notas
@Column({ type: 'text', nullable: true })
notes?: string;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,150 @@
/**
* MaintenancePlan Entity - Planes de Mantenimiento
*
* Planes de mantenimiento preventivo para activos.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
import { AssetCategory } from './asset-category.entity';
export type MaintenanceFrequency =
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthly'
| 'quarterly'
| 'semiannual'
| 'annual'
| 'by_hours'
| 'by_kilometers';
@Entity('maintenance_plans', { schema: 'assets' })
@Index(['tenantId', 'planCode'], { unique: true })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'categoryId'])
export class MaintenancePlan {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Identificacion
@Column({ name: 'plan_code', length: 50 })
planCode!: string;
@Column({ length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
// Aplica a
@Column({ name: 'asset_id', type: 'uuid', nullable: true })
assetId?: string;
@ManyToOne(() => Asset, { nullable: true })
@JoinColumn({ name: 'asset_id' })
asset?: Asset;
@Column({ name: 'category_id', type: 'uuid', nullable: true })
categoryId?: string;
@ManyToOne(() => AssetCategory, { nullable: true })
@JoinColumn({ name: 'category_id' })
category?: AssetCategory;
@Column({
name: 'asset_type',
type: 'enum',
enum: ['heavy_machinery', 'light_equipment', 'vehicle', 'tool', 'computer', 'furniture', 'other'],
enumName: 'asset_type',
nullable: true,
})
assetType?: string;
// Tipo y frecuencia
@Column({
name: 'maintenance_type',
type: 'enum',
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
enumName: 'maintenance_type',
default: 'preventive',
})
maintenanceType!: string;
@Column({
type: 'enum',
enum: ['daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual', 'by_hours', 'by_kilometers'],
enumName: 'maintenance_frequency',
})
frequency!: MaintenanceFrequency;
@Column({ name: 'frequency_value', type: 'int', nullable: true })
frequencyValue?: number;
// Actividades del plan
@Column({ type: 'jsonb' })
activities!: Record<string, any>[];
// Duracion estimada
@Column({ name: 'estimated_duration_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
estimatedDurationHours?: number;
// Recursos necesarios
@Column({ name: 'required_parts', type: 'jsonb', nullable: true })
requiredParts?: Record<string, any>[];
@Column({ name: 'required_tools', type: 'jsonb', nullable: true })
requiredTools?: Record<string, any>[];
@Column({ name: 'required_skills', type: 'varchar', array: true, nullable: true })
requiredSkills?: string[];
// Costos estimados
@Column({ name: 'estimated_labor_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
estimatedLaborCost?: number;
@Column({ name: 'estimated_parts_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
estimatedPartsCost?: number;
@Column({ name: 'estimated_total_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
estimatedTotalCost?: number;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
// Metadatos
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,86 @@
/**
* WorkOrderPart Entity - Partes/Refacciones Utilizadas
*
* Partes consumidas en ordenes de trabajo de mantenimiento.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { WorkOrder } from './work-order.entity';
@Entity('work_order_parts', { schema: 'assets' })
@Index(['workOrderId'])
export class WorkOrderPart {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Orden de trabajo
@Column({ name: 'work_order_id', type: 'uuid' })
workOrderId!: string;
@ManyToOne(() => WorkOrder, (wo) => wo.partsUsed, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'work_order_id' })
workOrder!: WorkOrder;
// Parte/Refaccion
@Column({ name: 'part_id', type: 'uuid', nullable: true })
partId?: string;
@Column({ name: 'part_code', length: 50, nullable: true })
partCode?: string;
@Column({ name: 'part_name', length: 255 })
partName!: string;
@Column({ name: 'part_description', type: 'text', nullable: true })
partDescription?: string;
// Cantidades
@Column({ name: 'quantity_required', type: 'decimal', precision: 10, scale: 2 })
quantityRequired!: number;
@Column({ name: 'quantity_used', type: 'decimal', precision: 10, scale: 2, nullable: true })
quantityUsed?: number;
// Costos
@Column({ name: 'unit_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
unitCost?: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
totalCost?: number;
// Origen
@Column({ name: 'from_inventory', type: 'boolean', default: false })
fromInventory!: boolean;
@Column({ name: 'purchase_order_id', type: 'uuid', nullable: true })
purchaseOrderId?: string;
// Notas
@Column({ type: 'text', nullable: true })
notes?: string;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,263 @@
/**
* WorkOrder Entity - Ordenes de Trabajo de Mantenimiento
*
* Registro de mantenimientos preventivos y correctivos.
*
* @module Assets (MAE-015)
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
import { WorkOrderPart } from './work-order-part.entity';
export type MaintenanceType = 'preventive' | 'corrective' | 'predictive' | 'emergency';
export type WorkOrderStatus = 'draft' | 'scheduled' | 'in_progress' | 'on_hold' | 'completed' | 'cancelled';
export type WorkOrderPriority = 'low' | 'medium' | 'high' | 'critical';
@Entity('work_orders', { schema: 'assets' })
@Index(['tenantId', 'workOrderNumber'], { unique: true })
@Index(['tenantId', 'assetId'])
@Index(['tenantId', 'status'])
@Index(['tenantId', 'scheduledStartDate'])
@Index(['tenantId', 'projectId'])
export class WorkOrder {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Identificacion
@Column({ name: 'work_order_number', length: 50 })
workOrderNumber!: string;
// Activo
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@ManyToOne(() => Asset)
@JoinColumn({ name: 'asset_id' })
asset!: Asset;
@Column({ name: 'asset_code', length: 50, nullable: true })
assetCode?: string;
@Column({ name: 'asset_name', length: 255, nullable: true })
assetName?: string;
// Tipo y estado
@Column({
name: 'maintenance_type',
type: 'enum',
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
enumName: 'maintenance_type',
})
maintenanceType!: MaintenanceType;
@Column({
type: 'enum',
enum: ['draft', 'scheduled', 'in_progress', 'on_hold', 'completed', 'cancelled'],
enumName: 'work_order_status',
default: 'draft',
})
status!: WorkOrderStatus;
@Column({
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
enumName: 'work_order_priority',
default: 'medium',
})
priority!: WorkOrderPriority;
// Origen
@Column({ name: 'schedule_id', type: 'uuid', nullable: true })
scheduleId?: string;
@Column({ name: 'plan_id', type: 'uuid', nullable: true })
planId?: string;
@Column({ name: 'is_scheduled', type: 'boolean', default: false })
isScheduled!: boolean;
// Descripcion
@Column({ length: 255 })
title!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'problem_reported', type: 'text', nullable: true })
problemReported?: string;
@Column({ type: 'text', nullable: true })
diagnosis?: string;
// Ubicacion
@Column({ name: 'project_id', type: 'uuid', nullable: true })
projectId?: string;
@Column({ name: 'project_name', length: 255, nullable: true })
projectName?: string;
@Column({ name: 'location_description', length: 255, nullable: true })
locationDescription?: string;
// Fechas
@Column({ name: 'requested_date', type: 'date' })
requestedDate!: Date;
@Column({ name: 'scheduled_start_date', type: 'date', nullable: true })
scheduledStartDate?: Date;
@Column({ name: 'scheduled_end_date', type: 'date', nullable: true })
scheduledEndDate?: Date;
@Column({ name: 'actual_start_date', type: 'date', nullable: true })
actualStartDate?: Date;
@Column({ name: 'actual_end_date', type: 'date', nullable: true })
actualEndDate?: Date;
// Metricas del equipo al momento
@Column({ name: 'hours_at_work_order', type: 'decimal', precision: 12, scale: 2, nullable: true })
hoursAtWorkOrder?: number;
@Column({ name: 'kilometers_at_work_order', type: 'decimal', precision: 12, scale: 2, nullable: true })
kilometersAtWorkOrder?: number;
// Asignacion
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
assignedToId?: string;
@Column({ name: 'assigned_to_name', length: 255, nullable: true })
assignedToName?: string;
@Column({ name: 'team_ids', type: 'uuid', array: true, nullable: true })
teamIds?: string[];
// Solicitante
@Column({ name: 'requested_by_id', type: 'uuid', nullable: true })
requestedById?: string;
@Column({ name: 'requested_by_name', length: 255, nullable: true })
requestedByName?: string;
// Aprobacion
@Column({ name: 'approved_by_id', type: 'uuid', nullable: true })
approvedById?: string;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt?: Date;
// Trabajo realizado
@Column({ name: 'work_performed', type: 'text', nullable: true })
workPerformed?: string;
@Column({ type: 'text', nullable: true })
findings?: string;
@Column({ type: 'text', nullable: true })
recommendations?: string;
// Checklist de actividades
@Column({ name: 'activities_checklist', type: 'jsonb', nullable: true })
activitiesChecklist?: Record<string, any>[];
// Tiempos
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
estimatedHours?: number;
@Column({ name: 'actual_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
actualHours?: number;
// Costos
@Column({ name: 'labor_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
laborCost!: number;
@Column({ name: 'parts_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
partsCost!: number;
@Column({ name: 'external_service_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
externalServiceCost!: number;
@Column({ name: 'other_costs', type: 'decimal', precision: 18, scale: 2, default: 0 })
otherCosts!: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
totalCost!: number;
// Partes utilizadas
@Column({ name: 'parts_used_count', type: 'int', default: 0 })
partsUsedCount!: number;
@OneToMany(() => WorkOrderPart, (part) => part.workOrder, { cascade: true })
partsUsed?: WorkOrderPart[];
// Documentos
@Column({ name: 'photos_before', type: 'jsonb', nullable: true })
photosBefore?: string[];
@Column({ name: 'photos_after', type: 'jsonb', nullable: true })
photosAfter?: string[];
@Column({ type: 'jsonb', nullable: true })
documents?: string[];
// Firma de conformidad
@Column({ name: 'completed_by_id', type: 'uuid', nullable: true })
completedById?: string;
@Column({ name: 'completed_by_name', length: 255, nullable: true })
completedByName?: string;
@Column({ name: 'completion_signature_url', length: 500, nullable: true })
completionSignatureUrl?: string;
@Column({ name: 'completion_notes', type: 'text', nullable: true })
completionNotes?: string;
// Seguimiento
@Column({ name: 'requires_followup', type: 'boolean', default: false })
requiresFollowup!: boolean;
@Column({ name: 'followup_notes', type: 'text', nullable: true })
followupNotes?: string;
@Column({ name: 'followup_work_order_id', type: 'uuid', nullable: true })
followupWorkOrderId?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoria
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,22 @@
/**
* Assets Module - Gestión de Activos Fijos y Maquinaria
* ERP Construccion - MAE-015
*
* Este módulo incluye:
* - Gestión de activos fijos, maquinaria y equipos
* - Asignaciones de activos a proyectos
* - Órdenes de trabajo de mantenimiento
* - Planes de mantenimiento preventivo
* - Historial de mantenimiento
* - Registro de combustible
* - Costos de operación (TCO)
*/
// Entities
export * from './entities';
// Services
export * from './services';
// Controllers
export * from './controllers';

View File

@ -0,0 +1,511 @@
/**
* Asset Service
* ERP Construccion - Modulo Activos (MAE-015)
*
* Logica de negocio para gestion de activos fijos y maquinaria.
*/
import { Repository, DataSource, ILike } from 'typeorm';
import { Asset, AssetType, AssetStatus, OwnershipType } from '../entities/asset.entity';
import { AssetCategory } from '../entities/asset-category.entity';
import { AssetAssignment } from '../entities/asset-assignment.entity';
// DTOs
export interface CreateAssetDto {
assetCode: string;
name: string;
description?: string;
categoryId?: string;
assetType: AssetType;
ownershipType?: OwnershipType;
brand?: string;
model?: string;
serialNumber?: string;
yearManufactured?: number;
specifications?: Record<string, any>;
capacity?: string;
powerRating?: string;
fuelType?: string;
fuelCapacity?: number;
purchaseDate?: Date;
purchasePrice?: number;
supplierId?: string;
usefulLifeYears?: number;
salvageValue?: number;
photoUrl?: string;
notes?: string;
tags?: string[];
}
export interface UpdateAssetDto extends Partial<CreateAssetDto> {
status?: AssetStatus;
currentProjectId?: string;
currentLocationName?: string;
currentLatitude?: number;
currentLongitude?: number;
currentHours?: number;
currentKilometers?: number;
assignedOperatorId?: string;
lastLocationUpdate?: Date;
lastUsageUpdate?: Date;
}
export interface AssetFilters {
assetType?: AssetType;
status?: AssetStatus;
ownershipType?: OwnershipType;
categoryId?: string;
projectId?: string;
search?: string;
tags?: string[];
}
export interface AssignAssetDto {
assetId: string;
projectId: string;
projectCode?: string;
projectName?: string;
startDate: Date;
operatorId?: string;
operatorName?: string;
responsibleId?: string;
responsibleName?: string;
locationInProject?: string;
dailyRate?: number;
hourlyRate?: number;
transferReason?: string;
}
export interface PaginationOptions {
page: number;
limit: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class AssetService {
private assetRepository: Repository<Asset>;
private categoryRepository: Repository<AssetCategory>;
private assignmentRepository: Repository<AssetAssignment>;
constructor(private dataSource: DataSource) {
this.assetRepository = dataSource.getRepository(Asset);
this.categoryRepository = dataSource.getRepository(AssetCategory);
this.assignmentRepository = dataSource.getRepository(AssetAssignment);
}
// ============================================
// ASSETS
// ============================================
/**
* Create a new asset
*/
async create(tenantId: string, dto: CreateAssetDto, userId?: string): Promise<Asset> {
// Check code uniqueness
const existing = await this.assetRepository.findOne({
where: { tenantId, assetCode: dto.assetCode },
});
if (existing) {
throw new Error(`Asset with code ${dto.assetCode} already exists`);
}
const asset = this.assetRepository.create({
tenantId,
...dto,
status: AssetStatus.AVAILABLE,
ownershipType: dto.ownershipType || OwnershipType.OWNED,
currentBookValue: dto.purchasePrice,
createdBy: userId,
});
return this.assetRepository.save(asset);
}
/**
* Find asset by ID
*/
async findById(tenantId: string, id: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { id, tenantId },
relations: ['category'],
});
}
/**
* Find asset by code
*/
async findByCode(tenantId: string, code: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { tenantId, assetCode: code },
relations: ['category'],
});
}
/**
* List assets with filters and pagination
*/
async findAll(
tenantId: string,
filters: AssetFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<Asset>> {
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
.leftJoinAndSelect('asset.category', 'category')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL');
if (filters.assetType) {
queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: filters.assetType });
}
if (filters.status) {
queryBuilder.andWhere('asset.status = :status', { status: filters.status });
}
if (filters.ownershipType) {
queryBuilder.andWhere('asset.ownership_type = :ownershipType', { ownershipType: filters.ownershipType });
}
if (filters.categoryId) {
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: filters.categoryId });
}
if (filters.projectId) {
queryBuilder.andWhere('asset.current_project_id = :projectId', { projectId: filters.projectId });
}
if (filters.search) {
queryBuilder.andWhere(
'(asset.asset_code ILIKE :search OR asset.name ILIKE :search OR asset.serial_number ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
if (filters.tags && filters.tags.length > 0) {
queryBuilder.andWhere('asset.tags && :tags', { tags: filters.tags });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('asset.name', 'ASC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update asset
*/
async update(tenantId: string, id: string, dto: UpdateAssetDto, userId?: string): Promise<Asset | null> {
const asset = await this.findById(tenantId, id);
if (!asset) return null;
// Update location timestamp if location changed
if (dto.currentLatitude !== undefined || dto.currentLongitude !== undefined) {
dto.lastLocationUpdate = new Date();
}
// Update usage timestamp if hours/km changed
if (dto.currentHours !== undefined || dto.currentKilometers !== undefined) {
dto.lastUsageUpdate = new Date();
}
Object.assign(asset, dto, { updatedBy: userId });
return this.assetRepository.save(asset);
}
/**
* Update asset status
*/
async updateStatus(tenantId: string, id: string, status: AssetStatus, userId?: string): Promise<Asset | null> {
return this.update(tenantId, id, { status }, userId);
}
/**
* Update asset usage metrics (hours, km)
*/
async updateUsage(
tenantId: string,
id: string,
hours?: number,
kilometers?: number,
userId?: string
): Promise<Asset | null> {
const asset = await this.findById(tenantId, id);
if (!asset) return null;
if (hours !== undefined) {
asset.currentHours = hours;
}
if (kilometers !== undefined) {
asset.currentKilometers = kilometers;
}
asset.lastUsageUpdate = new Date();
asset.updatedBy = userId;
return this.assetRepository.save(asset);
}
/**
* Soft delete asset
*/
async delete(tenantId: string, id: string, userId?: string): Promise<boolean> {
const result = await this.assetRepository.update(
{ id, tenantId },
{ deletedAt: new Date(), status: AssetStatus.RETIRED, updatedBy: userId }
);
return (result.affected ?? 0) > 0;
}
// ============================================
// ASSIGNMENTS
// ============================================
/**
* Assign asset to project
*/
async assignToProject(tenantId: string, dto: AssignAssetDto, userId?: string): Promise<AssetAssignment> {
// Close current assignment if exists
await this.assignmentRepository.update(
{ tenantId, assetId: dto.assetId, isCurrent: true },
{ isCurrent: false, endDate: new Date() }
);
// Get asset for current metrics
const asset = await this.findById(tenantId, dto.assetId);
if (!asset) {
throw new Error('Asset not found');
}
// Create new assignment
const assignment = this.assignmentRepository.create({
tenantId,
assetId: dto.assetId,
projectId: dto.projectId,
projectCode: dto.projectCode,
projectName: dto.projectName,
startDate: dto.startDate,
isCurrent: true,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
responsibleId: dto.responsibleId,
responsibleName: dto.responsibleName,
locationInProject: dto.locationInProject,
hoursAtStart: asset.currentHours,
kilometersAtStart: asset.currentKilometers,
dailyRate: dto.dailyRate,
hourlyRate: dto.hourlyRate,
transferReason: dto.transferReason,
createdBy: userId,
});
const savedAssignment = await this.assignmentRepository.save(assignment);
// Update asset with new project
await this.update(tenantId, dto.assetId, {
currentProjectId: dto.projectId,
status: AssetStatus.ASSIGNED,
assignedOperatorId: dto.operatorId,
}, userId);
return savedAssignment;
}
/**
* Get current assignment for asset
*/
async getCurrentAssignment(tenantId: string, assetId: string): Promise<AssetAssignment | null> {
return this.assignmentRepository.findOne({
where: { tenantId, assetId, isCurrent: true },
});
}
/**
* Get assignment history for asset
*/
async getAssignmentHistory(
tenantId: string,
assetId: string,
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<AssetAssignment>> {
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await this.assignmentRepository.findAndCount({
where: { tenantId, assetId },
order: { startDate: 'DESC' },
skip,
take: pagination.limit,
});
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Return asset from project (close assignment)
*/
async returnFromProject(tenantId: string, assetId: string, endDate: Date, userId?: string): Promise<boolean> {
const assignment = await this.getCurrentAssignment(tenantId, assetId);
if (!assignment) return false;
const asset = await this.findById(tenantId, assetId);
if (!asset) return false;
// Close assignment
assignment.isCurrent = false;
assignment.endDate = endDate;
assignment.hoursAtEnd = asset.currentHours;
assignment.kilometersAtEnd = asset.currentKilometers;
assignment.updatedBy = userId;
await this.assignmentRepository.save(assignment);
// Update asset status
await this.update(tenantId, assetId, {
currentProjectId: undefined,
status: AssetStatus.AVAILABLE,
}, userId);
return true;
}
// ============================================
// CATEGORIES
// ============================================
/**
* Create category
*/
async createCategory(tenantId: string, data: Partial<AssetCategory>, userId?: string): Promise<AssetCategory> {
const category = this.categoryRepository.create({
tenantId,
...data,
createdBy: userId,
});
return this.categoryRepository.save(category);
}
/**
* Get all categories
*/
async getCategories(tenantId: string): Promise<AssetCategory[]> {
return this.categoryRepository.find({
where: { tenantId, isActive: true, deletedAt: null },
order: { level: 'ASC', name: 'ASC' },
});
}
// ============================================
// STATISTICS
// ============================================
/**
* Get asset statistics
*/
async getStatistics(tenantId: string): Promise<{
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
totalValue: number;
maintenanceDue: number;
}> {
const [total, byStatusRaw, byTypeRaw, valueResult, maintenanceDue] = await Promise.all([
this.assetRepository.count({ where: { tenantId, deletedAt: null } }),
this.assetRepository.createQueryBuilder('asset')
.select('asset.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.groupBy('asset.status')
.getRawMany(),
this.assetRepository.createQueryBuilder('asset')
.select('asset.asset_type', 'type')
.addSelect('COUNT(*)', 'count')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.groupBy('asset.asset_type')
.getRawMany(),
this.assetRepository.createQueryBuilder('asset')
.select('SUM(asset.current_book_value)', 'total')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.getRawOne(),
this.assetRepository.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.andWhere('asset.next_maintenance_date <= :date', { date: new Date() })
.getCount(),
]);
const byStatus: Record<string, number> = {};
byStatusRaw.forEach((row: any) => {
byStatus[row.status] = parseInt(row.count, 10);
});
const byType: Record<string, number> = {};
byTypeRaw.forEach((row: any) => {
byType[row.type] = parseInt(row.count, 10);
});
return {
total,
byStatus,
byType,
totalValue: parseFloat(valueResult?.total) || 0,
maintenanceDue,
};
}
/**
* Get assets needing maintenance
*/
async getAssetsNeedingMaintenance(tenantId: string): Promise<Asset[]> {
const today = new Date();
return this.assetRepository.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED })
.andWhere(
'(asset.next_maintenance_date <= :today OR asset.current_hours >= asset.next_maintenance_hours OR asset.current_kilometers >= asset.next_maintenance_kilometers)',
{ today }
)
.orderBy('asset.next_maintenance_date', 'ASC')
.getMany();
}
/**
* Search assets for autocomplete
*/
async search(tenantId: string, query: string, limit = 10): Promise<Asset[]> {
return this.assetRepository.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL')
.andWhere(
'(asset.asset_code ILIKE :query OR asset.name ILIKE :query OR asset.serial_number ILIKE :query)',
{ query: `%${query}%` }
)
.orderBy('asset.name', 'ASC')
.take(limit)
.getMany();
}
}

View File

@ -0,0 +1,232 @@
/**
* FuelLog Service
* ERP Construccion - Modulo Activos (MAE-015)
*
* Logica de negocio para registro de combustible y calculo de rendimiento.
*/
import { Repository, DataSource, Between } from 'typeorm';
import { FuelLog } from '../entities/fuel-log.entity';
import { Asset } from '../entities/asset.entity';
// DTOs
export interface CreateFuelLogDto {
assetId: string;
logDate: Date;
logTime?: string;
projectId?: string;
location?: string;
fuelType: string;
quantityLiters: number;
unitPrice: number;
odometerReading?: number;
hoursReading?: number;
vendorName?: string;
invoiceNumber?: string;
operatorId?: string;
operatorName?: string;
notes?: string;
}
export interface FuelLogFilters {
assetId?: string;
projectId?: string;
fuelType?: string;
fromDate?: Date;
toDate?: Date;
}
export interface FuelStatistics {
totalLiters: number;
totalCost: number;
avgLitersPer100km: number;
avgLitersPerHour: number;
logsCount: number;
}
export class FuelLogService {
private fuelLogRepository: Repository<FuelLog>;
private assetRepository: Repository<Asset>;
constructor(private dataSource: DataSource) {
this.fuelLogRepository = dataSource.getRepository(FuelLog);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Create a new fuel log entry
*/
async create(tenantId: string, dto: CreateFuelLogDto, userId?: string): Promise<FuelLog> {
// Calculate total cost
const totalCost = dto.quantityLiters * dto.unitPrice;
// Get last fuel log for efficiency calculation
const lastLog = await this.fuelLogRepository.findOne({
where: { tenantId, assetId: dto.assetId },
order: { logDate: 'DESC', createdAt: 'DESC' },
});
let kilometersSinceLast: number | undefined;
let hoursSinceLast: number | undefined;
let litersPer100km: number | undefined;
let litersPerHour: number | undefined;
if (lastLog) {
if (dto.odometerReading && lastLog.odometerReading) {
kilometersSinceLast = dto.odometerReading - Number(lastLog.odometerReading);
if (kilometersSinceLast > 0 && dto.quantityLiters > 0) {
litersPer100km = (dto.quantityLiters / kilometersSinceLast) * 100;
}
}
if (dto.hoursReading && lastLog.hoursReading) {
hoursSinceLast = dto.hoursReading - Number(lastLog.hoursReading);
if (hoursSinceLast > 0 && dto.quantityLiters > 0) {
litersPerHour = dto.quantityLiters / hoursSinceLast;
}
}
}
const fuelLog = this.fuelLogRepository.create({
tenantId,
assetId: dto.assetId,
logDate: dto.logDate,
logTime: dto.logTime,
projectId: dto.projectId,
location: dto.location,
fuelType: dto.fuelType,
quantityLiters: dto.quantityLiters,
unitPrice: dto.unitPrice,
totalCost,
odometerReading: dto.odometerReading,
hoursReading: dto.hoursReading,
kilometersSinceLast,
hoursSinceLast,
litersPer100km,
litersPerHour,
vendorName: dto.vendorName,
invoiceNumber: dto.invoiceNumber,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
notes: dto.notes,
createdBy: userId,
});
const savedLog = await this.fuelLogRepository.save(fuelLog);
// Update asset current readings if provided
if (dto.odometerReading || dto.hoursReading) {
await this.assetRepository.update(
{ id: dto.assetId, tenantId },
{
currentKilometers: dto.odometerReading,
currentHours: dto.hoursReading,
lastUsageUpdate: new Date(),
}
);
}
return savedLog;
}
/**
* Find fuel log by ID
*/
async findById(tenantId: string, id: string): Promise<FuelLog | null> {
return this.fuelLogRepository.findOne({
where: { id, tenantId },
relations: ['asset'],
});
}
/**
* List fuel logs with filters
*/
async findAll(
tenantId: string,
filters: FuelLogFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.fuelLogRepository.createQueryBuilder('fl')
.leftJoinAndSelect('fl.asset', 'asset')
.where('fl.tenant_id = :tenantId', { tenantId });
if (filters.assetId) {
queryBuilder.andWhere('fl.asset_id = :assetId', { assetId: filters.assetId });
}
if (filters.projectId) {
queryBuilder.andWhere('fl.project_id = :projectId', { projectId: filters.projectId });
}
if (filters.fuelType) {
queryBuilder.andWhere('fl.fuel_type = :fuelType', { fuelType: filters.fuelType });
}
if (filters.fromDate) {
queryBuilder.andWhere('fl.log_date >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('fl.log_date <= :toDate', { toDate: filters.toDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('fl.log_date', 'DESC')
.addOrderBy('fl.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Get fuel statistics for an asset
*/
async getAssetStatistics(
tenantId: string,
assetId: string,
fromDate?: Date,
toDate?: Date
): Promise<FuelStatistics> {
const queryBuilder = this.fuelLogRepository.createQueryBuilder('fl')
.where('fl.tenant_id = :tenantId', { tenantId })
.andWhere('fl.asset_id = :assetId', { assetId });
if (fromDate) {
queryBuilder.andWhere('fl.log_date >= :fromDate', { fromDate });
}
if (toDate) {
queryBuilder.andWhere('fl.log_date <= :toDate', { toDate });
}
const result = await queryBuilder
.select('SUM(fl.quantity_liters)', 'totalLiters')
.addSelect('SUM(fl.total_cost)', 'totalCost')
.addSelect('AVG(fl.liters_per_100km)', 'avgLitersPer100km')
.addSelect('AVG(fl.liters_per_hour)', 'avgLitersPerHour')
.addSelect('COUNT(*)', 'logsCount')
.getRawOne();
return {
totalLiters: parseFloat(result?.totalLiters) || 0,
totalCost: parseFloat(result?.totalCost) || 0,
avgLitersPer100km: parseFloat(result?.avgLitersPer100km) || 0,
avgLitersPerHour: parseFloat(result?.avgLitersPerHour) || 0,
logsCount: parseInt(result?.logsCount, 10) || 0,
};
}
/**
* Delete fuel log entry
*/
async delete(tenantId: string, id: string): Promise<boolean> {
const result = await this.fuelLogRepository.delete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
}

View File

@ -0,0 +1,8 @@
/**
* Assets Services Index
* @module Assets (MAE-015)
*/
export * from './asset.service';
export * from './work-order.service';
export * from './fuel-log.service';

View File

@ -0,0 +1,750 @@
/**
* WorkOrder Service
* ERP Construccion - Modulo Activos (MAE-015)
*
* Logica de negocio para ordenes de trabajo de mantenimiento.
*/
import { Repository, DataSource, LessThanOrEqual, In } from 'typeorm';
import { WorkOrder, WorkOrderStatus, WorkOrderPriority, MaintenanceType } from '../entities/work-order.entity';
import { WorkOrderPart } from '../entities/work-order-part.entity';
import { MaintenanceHistory } from '../entities/maintenance-history.entity';
import { MaintenancePlan } from '../entities/maintenance-plan.entity';
import { Asset } from '../entities/asset.entity';
// DTOs
export interface CreateWorkOrderDto {
assetId: string;
maintenanceType: MaintenanceType;
priority?: WorkOrderPriority;
title: string;
description?: string;
problemReported?: string;
projectId?: string;
projectName?: string;
scheduledStartDate?: Date;
scheduledEndDate?: Date;
assignedToId?: string;
assignedToName?: string;
estimatedHours?: number;
activitiesChecklist?: Record<string, any>[];
isScheduled?: boolean;
scheduleId?: string;
planId?: string;
}
export interface UpdateWorkOrderDto {
status?: WorkOrderStatus;
priority?: WorkOrderPriority;
title?: string;
description?: string;
diagnosis?: string;
scheduledStartDate?: Date;
scheduledEndDate?: Date;
actualStartDate?: Date;
actualEndDate?: Date;
assignedToId?: string;
assignedToName?: string;
workPerformed?: string;
findings?: string;
recommendations?: string;
activitiesChecklist?: Record<string, any>[];
actualHours?: number;
laborCost?: number;
externalServiceCost?: number;
otherCosts?: number;
requiresFollowup?: boolean;
followupNotes?: string;
notes?: string;
}
export interface AddPartDto {
partId?: string;
partCode?: string;
partName: string;
partDescription?: string;
quantityRequired: number;
quantityUsed?: number;
unitCost?: number;
fromInventory?: boolean;
}
export interface WorkOrderFilters {
status?: WorkOrderStatus;
priority?: WorkOrderPriority;
maintenanceType?: MaintenanceType;
assetId?: string;
projectId?: string;
assignedToId?: string;
fromDate?: Date;
toDate?: Date;
search?: string;
}
export interface CompleteWorkOrderDto {
workPerformed: string;
findings?: string;
recommendations?: string;
actualHours: number;
laborCost: number;
completedById?: string;
completedByName?: string;
completionNotes?: string;
photosAfter?: string[];
requiresFollowup?: boolean;
followupNotes?: string;
}
export class WorkOrderService {
private workOrderRepository: Repository<WorkOrder>;
private partRepository: Repository<WorkOrderPart>;
private historyRepository: Repository<MaintenanceHistory>;
private planRepository: Repository<MaintenancePlan>;
private assetRepository: Repository<Asset>;
constructor(private dataSource: DataSource) {
this.workOrderRepository = dataSource.getRepository(WorkOrder);
this.partRepository = dataSource.getRepository(WorkOrderPart);
this.historyRepository = dataSource.getRepository(MaintenanceHistory);
this.planRepository = dataSource.getRepository(MaintenancePlan);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Generate next work order number
*/
private async generateWorkOrderNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `OT-${year}-`;
const lastOrder = await this.workOrderRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastOrder?.workOrderNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastOrder.workOrderNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
return `${prefix}${sequence.toString().padStart(5, '0')}`;
}
/**
* Create a new work order
*/
async create(tenantId: string, dto: CreateWorkOrderDto, userId?: string): Promise<WorkOrder> {
// Get asset info
const asset = await this.assetRepository.findOne({
where: { id: dto.assetId, tenantId },
});
if (!asset) {
throw new Error('Asset not found');
}
const workOrderNumber = await this.generateWorkOrderNumber(tenantId);
const workOrder = this.workOrderRepository.create({
tenantId,
workOrderNumber,
assetId: dto.assetId,
assetCode: asset.assetCode,
assetName: asset.name,
maintenanceType: dto.maintenanceType,
priority: dto.priority || WorkOrderPriority.MEDIUM,
status: WorkOrderStatus.DRAFT,
title: dto.title,
description: dto.description,
problemReported: dto.problemReported,
projectId: dto.projectId || asset.currentProjectId,
projectName: dto.projectName,
requestedDate: new Date(),
scheduledStartDate: dto.scheduledStartDate,
scheduledEndDate: dto.scheduledEndDate,
hoursAtWorkOrder: asset.currentHours,
kilometersAtWorkOrder: asset.currentKilometers,
assignedToId: dto.assignedToId,
assignedToName: dto.assignedToName,
requestedById: userId,
estimatedHours: dto.estimatedHours,
activitiesChecklist: dto.activitiesChecklist,
isScheduled: dto.isScheduled || false,
scheduleId: dto.scheduleId,
planId: dto.planId,
createdBy: userId,
});
return this.workOrderRepository.save(workOrder);
}
/**
* Find work order by ID
*/
async findById(tenantId: string, id: string): Promise<WorkOrder | null> {
return this.workOrderRepository.findOne({
where: { id, tenantId },
relations: ['asset', 'partsUsed'],
});
}
/**
* Find work order by number
*/
async findByNumber(tenantId: string, workOrderNumber: string): Promise<WorkOrder | null> {
return this.workOrderRepository.findOne({
where: { tenantId, workOrderNumber },
relations: ['asset', 'partsUsed'],
});
}
/**
* List work orders with filters
*/
async findAll(
tenantId: string,
filters: WorkOrderFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.workOrderRepository.createQueryBuilder('wo')
.leftJoinAndSelect('wo.asset', 'asset')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL');
if (filters.status) {
queryBuilder.andWhere('wo.status = :status', { status: filters.status });
}
if (filters.priority) {
queryBuilder.andWhere('wo.priority = :priority', { priority: filters.priority });
}
if (filters.maintenanceType) {
queryBuilder.andWhere('wo.maintenance_type = :maintenanceType', { maintenanceType: filters.maintenanceType });
}
if (filters.assetId) {
queryBuilder.andWhere('wo.asset_id = :assetId', { assetId: filters.assetId });
}
if (filters.projectId) {
queryBuilder.andWhere('wo.project_id = :projectId', { projectId: filters.projectId });
}
if (filters.assignedToId) {
queryBuilder.andWhere('wo.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
}
if (filters.fromDate) {
queryBuilder.andWhere('wo.requested_date >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('wo.requested_date <= :toDate', { toDate: filters.toDate });
}
if (filters.search) {
queryBuilder.andWhere(
'(wo.work_order_number ILIKE :search OR wo.title ILIKE :search OR wo.asset_name ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('wo.requested_date', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update work order
*/
async update(tenantId: string, id: string, dto: UpdateWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
// Handle status transitions
if (dto.status && dto.status !== workOrder.status) {
this.validateStatusTransition(workOrder.status, dto.status);
this.applyStatusSideEffects(workOrder, dto.status);
}
Object.assign(workOrder, dto, { updatedBy: userId });
// Recalculate total cost
workOrder.totalCost =
(workOrder.laborCost || 0) +
(workOrder.partsCost || 0) +
(workOrder.externalServiceCost || 0) +
(workOrder.otherCosts || 0);
return this.workOrderRepository.save(workOrder);
}
/**
* Validate status transition
*/
private validateStatusTransition(from: WorkOrderStatus, to: WorkOrderStatus): void {
const validTransitions: Record<WorkOrderStatus, WorkOrderStatus[]> = {
[WorkOrderStatus.DRAFT]: [WorkOrderStatus.SCHEDULED, WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
[WorkOrderStatus.SCHEDULED]: [WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
[WorkOrderStatus.IN_PROGRESS]: [WorkOrderStatus.ON_HOLD, WorkOrderStatus.COMPLETED, WorkOrderStatus.CANCELLED],
[WorkOrderStatus.ON_HOLD]: [WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
[WorkOrderStatus.COMPLETED]: [],
[WorkOrderStatus.CANCELLED]: [],
};
if (!validTransitions[from].includes(to)) {
throw new Error(`Invalid status transition from ${from} to ${to}`);
}
}
/**
* Apply side effects when status changes
*/
private applyStatusSideEffects(workOrder: WorkOrder, newStatus: WorkOrderStatus): void {
const now = new Date();
switch (newStatus) {
case WorkOrderStatus.IN_PROGRESS:
if (!workOrder.actualStartDate) {
workOrder.actualStartDate = now;
}
break;
case WorkOrderStatus.COMPLETED:
workOrder.actualEndDate = now;
break;
}
}
/**
* Start work order (change to in_progress)
*/
async start(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
return this.update(tenantId, id, { status: WorkOrderStatus.IN_PROGRESS }, userId);
}
/**
* Complete work order
*/
async complete(tenantId: string, id: string, dto: CompleteWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
// Update work order
workOrder.status = WorkOrderStatus.COMPLETED;
workOrder.actualEndDate = new Date();
workOrder.workPerformed = dto.workPerformed;
workOrder.findings = dto.findings;
workOrder.recommendations = dto.recommendations;
workOrder.actualHours = dto.actualHours;
workOrder.laborCost = dto.laborCost;
workOrder.completedById = dto.completedById || userId;
workOrder.completedByName = dto.completedByName;
workOrder.completionNotes = dto.completionNotes;
workOrder.photosAfter = dto.photosAfter;
workOrder.requiresFollowup = dto.requiresFollowup || false;
workOrder.followupNotes = dto.followupNotes;
workOrder.updatedBy = userId;
// Recalculate total cost
workOrder.totalCost =
(workOrder.laborCost || 0) +
(workOrder.partsCost || 0) +
(workOrder.externalServiceCost || 0) +
(workOrder.otherCosts || 0);
const savedWorkOrder = await this.workOrderRepository.save(workOrder);
// Create maintenance history record
await this.historyRepository.save({
tenantId,
assetId: workOrder.assetId,
workOrderId: workOrder.id,
maintenanceDate: new Date(),
maintenanceType: workOrder.maintenanceType,
description: workOrder.title,
workPerformed: dto.workPerformed,
hoursAtMaintenance: workOrder.hoursAtWorkOrder,
kilometersAtMaintenance: workOrder.kilometersAtWorkOrder,
laborCost: workOrder.laborCost,
partsCost: workOrder.partsCost,
totalCost: workOrder.totalCost,
performedById: dto.completedById || userId,
performedByName: dto.completedByName,
createdBy: userId,
});
return savedWorkOrder;
}
/**
* Add part to work order
*/
async addPart(tenantId: string, workOrderId: string, dto: AddPartDto, userId?: string): Promise<WorkOrderPart> {
const workOrder = await this.findById(tenantId, workOrderId);
if (!workOrder) {
throw new Error('Work order not found');
}
const totalCost = dto.unitCost && dto.quantityUsed
? dto.unitCost * dto.quantityUsed
: undefined;
const part = this.partRepository.create({
tenantId,
workOrderId,
partId: dto.partId,
partCode: dto.partCode,
partName: dto.partName,
partDescription: dto.partDescription,
quantityRequired: dto.quantityRequired,
quantityUsed: dto.quantityUsed,
unitCost: dto.unitCost,
totalCost,
fromInventory: dto.fromInventory || false,
createdBy: userId,
});
const savedPart = await this.partRepository.save(part);
// Update parts cost in work order
await this.recalculatePartsCost(workOrderId);
return savedPart;
}
/**
* Recalculate parts cost for work order
*/
private async recalculatePartsCost(workOrderId: string): Promise<void> {
const parts = await this.partRepository.find({ where: { workOrderId } });
const partsCost = parts.reduce((sum, part) => sum + (Number(part.totalCost) || 0), 0);
const partsCount = parts.length;
await this.workOrderRepository.update(
{ id: workOrderId },
{
partsCost,
partsUsedCount: partsCount,
totalCost: () => `labor_cost + ${partsCost} + external_service_cost + other_costs`,
}
);
}
/**
* Get work order statistics
*/
async getStatistics(tenantId: string): Promise<{
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
pendingCount: number;
inProgressCount: number;
completedThisMonth: number;
totalCostThisMonth: number;
}> {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [total, byStatusRaw, byTypeRaw, pendingCount, inProgressCount, completedThisMonth, costResult] =
await Promise.all([
this.workOrderRepository.count({ where: { tenantId, deletedAt: null } }),
this.workOrderRepository.createQueryBuilder('wo')
.select('wo.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL')
.groupBy('wo.status')
.getRawMany(),
this.workOrderRepository.createQueryBuilder('wo')
.select('wo.maintenance_type', 'type')
.addSelect('COUNT(*)', 'count')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL')
.groupBy('wo.maintenance_type')
.getRawMany(),
this.workOrderRepository.count({
where: { tenantId, status: WorkOrderStatus.DRAFT, deletedAt: null },
}),
this.workOrderRepository.count({
where: { tenantId, status: WorkOrderStatus.IN_PROGRESS, deletedAt: null },
}),
this.workOrderRepository.createQueryBuilder('wo')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.status = :status', { status: WorkOrderStatus.COMPLETED })
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
.getCount(),
this.workOrderRepository.createQueryBuilder('wo')
.select('SUM(wo.total_cost)', 'total')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.status = :status', { status: WorkOrderStatus.COMPLETED })
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
.getRawOne(),
]);
const byStatus: Record<string, number> = {};
byStatusRaw.forEach((row: any) => {
byStatus[row.status] = parseInt(row.count, 10);
});
const byType: Record<string, number> = {};
byTypeRaw.forEach((row: any) => {
byType[row.type] = parseInt(row.count, 10);
});
return {
total,
byStatus,
byType,
pendingCount,
inProgressCount,
completedThisMonth,
totalCostThisMonth: parseFloat(costResult?.total) || 0,
};
}
/**
* Get work orders grouped by status (for Kanban)
*/
async getByStatus(tenantId: string): Promise<Record<WorkOrderStatus, WorkOrder[]>> {
const workOrders = await this.workOrderRepository.find({
where: { tenantId, deletedAt: null },
relations: ['asset'],
order: { requestedDate: 'DESC' },
});
const grouped: Record<WorkOrderStatus, WorkOrder[]> = {
[WorkOrderStatus.DRAFT]: [],
[WorkOrderStatus.SCHEDULED]: [],
[WorkOrderStatus.IN_PROGRESS]: [],
[WorkOrderStatus.ON_HOLD]: [],
[WorkOrderStatus.COMPLETED]: [],
[WorkOrderStatus.CANCELLED]: [],
};
for (const wo of workOrders) {
grouped[wo.status].push(wo);
}
return grouped;
}
/**
* Put work order on hold
*/
async hold(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, WorkOrderStatus.ON_HOLD);
workOrder.status = WorkOrderStatus.ON_HOLD;
workOrder.notes = reason ? `${workOrder.notes || ''}\n[ON HOLD] ${reason}` : workOrder.notes;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Resume work order from hold
*/
async resume(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, WorkOrderStatus.IN_PROGRESS);
workOrder.status = WorkOrderStatus.IN_PROGRESS;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Cancel work order
*/
async cancel(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, WorkOrderStatus.CANCELLED);
workOrder.status = WorkOrderStatus.CANCELLED;
workOrder.notes = reason ? `${workOrder.notes || ''}\n[CANCELLED] ${reason}` : workOrder.notes;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Soft delete work order
*/
async delete(tenantId: string, id: string, userId?: string): Promise<boolean> {
const result = await this.workOrderRepository.update(
{ id, tenantId },
{ deletedAt: new Date(), updatedBy: userId }
);
return (result.affected ?? 0) > 0;
}
/**
* Get parts for a work order
*/
async getParts(tenantId: string, workOrderId: string): Promise<WorkOrderPart[]> {
return this.partRepository.find({
where: { tenantId, workOrderId },
order: { createdAt: 'ASC' },
});
}
/**
* Update a part
*/
async updatePart(tenantId: string, partId: string, dto: Partial<AddPartDto>, userId?: string): Promise<WorkOrderPart | null> {
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
if (!part) return null;
if (dto.quantityUsed !== undefined) {
part.quantityUsed = dto.quantityUsed;
}
if (dto.unitCost !== undefined) {
part.unitCost = dto.unitCost;
}
if (part.unitCost && part.quantityUsed) {
part.totalCost = part.unitCost * part.quantityUsed;
}
part.updatedBy = userId;
const savedPart = await this.partRepository.save(part);
await this.recalculatePartsCost(part.workOrderId);
return savedPart;
}
/**
* Remove a part from work order
*/
async removePart(tenantId: string, partId: string, userId?: string): Promise<boolean> {
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
if (!part) return false;
const workOrderId = part.workOrderId;
await this.partRepository.remove(part);
await this.recalculatePartsCost(workOrderId);
return true;
}
/**
* Get overdue work orders
*/
async getOverdue(tenantId: string): Promise<WorkOrder[]> {
const now = new Date();
return this.workOrderRepository.find({
where: {
tenantId,
deletedAt: undefined,
status: In([WorkOrderStatus.DRAFT, WorkOrderStatus.SCHEDULED, WorkOrderStatus.IN_PROGRESS]),
scheduledEndDate: LessThanOrEqual(now),
},
relations: ['asset'],
order: { scheduledEndDate: 'ASC' },
});
}
/**
* Get maintenance plans
*/
async getMaintenancePlans(tenantId: string, assetId?: string): Promise<MaintenancePlan[]> {
const where: any = { tenantId, isActive: true, deletedAt: undefined };
if (assetId) {
where.assetId = assetId;
}
return this.planRepository.find({
where,
relations: ['asset', 'category'],
order: { name: 'ASC' },
});
}
/**
* Create a maintenance plan
*/
async createMaintenancePlan(tenantId: string, data: Partial<MaintenancePlan>, userId?: string): Promise<MaintenancePlan> {
const plan = this.planRepository.create({
tenantId,
...data,
isActive: true,
createdBy: userId,
});
return this.planRepository.save(plan);
}
/**
* Generate work orders from a maintenance plan
*/
async generateFromPlan(tenantId: string, planId: string, userId?: string): Promise<WorkOrder[]> {
const plan = await this.planRepository.findOne({
where: { id: planId, tenantId },
relations: ['asset'],
});
if (!plan) {
throw new Error('Maintenance plan not found');
}
const assets: Asset[] = [];
if (plan.assetId && plan.asset) {
assets.push(plan.asset);
} else if (plan.categoryId || plan.assetType) {
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL');
if (plan.categoryId) {
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: plan.categoryId });
}
if (plan.assetType) {
queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: plan.assetType });
}
const foundAssets = await queryBuilder.getMany();
assets.push(...foundAssets);
}
const createdOrders: WorkOrder[] = [];
for (const asset of assets) {
const workOrder = await this.create(tenantId, {
assetId: asset.id,
maintenanceType: plan.maintenanceType as MaintenanceType,
title: `[${plan.planCode}] ${plan.name}`,
description: plan.description,
estimatedHours: plan.estimatedDurationHours ? Number(plan.estimatedDurationHours) : undefined,
activitiesChecklist: plan.activities,
isScheduled: true,
planId: plan.id,
}, userId);
createdOrders.push(workOrder);
}
return createdOrders;
}
}