[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:
parent
bdf8c878e8
commit
5f9c30d268
360
src/modules/assets/controllers/asset.controller.ts
Normal file
360
src/modules/assets/controllers/asset.controller.ts
Normal 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;
|
||||
}
|
||||
118
src/modules/assets/controllers/fuel-log.controller.ts
Normal file
118
src/modules/assets/controllers/fuel-log.controller.ts
Normal 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;
|
||||
}
|
||||
8
src/modules/assets/controllers/index.ts
Normal file
8
src/modules/assets/controllers/index.ts
Normal 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';
|
||||
418
src/modules/assets/controllers/work-order.controller.ts
Normal file
418
src/modules/assets/controllers/work-order.controller.ts
Normal 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;
|
||||
}
|
||||
134
src/modules/assets/entities/asset-assignment.entity.ts
Normal file
134
src/modules/assets/entities/asset-assignment.entity.ts
Normal 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;
|
||||
}
|
||||
95
src/modules/assets/entities/asset-category.entity.ts
Normal file
95
src/modules/assets/entities/asset-category.entity.ts
Normal 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;
|
||||
}
|
||||
132
src/modules/assets/entities/asset-cost.entity.ts
Normal file
132
src/modules/assets/entities/asset-cost.entity.ts
Normal 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;
|
||||
}
|
||||
284
src/modules/assets/entities/asset.entity.ts
Normal file
284
src/modules/assets/entities/asset.entity.ts
Normal 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;
|
||||
}
|
||||
113
src/modules/assets/entities/fuel-log.entity.ts
Normal file
113
src/modules/assets/entities/fuel-log.entity.ts
Normal 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;
|
||||
}
|
||||
14
src/modules/assets/entities/index.ts
Normal file
14
src/modules/assets/entities/index.ts
Normal 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';
|
||||
108
src/modules/assets/entities/maintenance-history.entity.ts
Normal file
108
src/modules/assets/entities/maintenance-history.entity.ts
Normal 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;
|
||||
}
|
||||
150
src/modules/assets/entities/maintenance-plan.entity.ts
Normal file
150
src/modules/assets/entities/maintenance-plan.entity.ts
Normal 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;
|
||||
}
|
||||
86
src/modules/assets/entities/work-order-part.entity.ts
Normal file
86
src/modules/assets/entities/work-order-part.entity.ts
Normal 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;
|
||||
}
|
||||
263
src/modules/assets/entities/work-order.entity.ts
Normal file
263
src/modules/assets/entities/work-order.entity.ts
Normal 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;
|
||||
}
|
||||
22
src/modules/assets/index.ts
Normal file
22
src/modules/assets/index.ts
Normal 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';
|
||||
511
src/modules/assets/services/asset.service.ts
Normal file
511
src/modules/assets/services/asset.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
232
src/modules/assets/services/fuel-log.service.ts
Normal file
232
src/modules/assets/services/fuel-log.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/modules/assets/services/index.ts
Normal file
8
src/modules/assets/services/index.ts
Normal 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';
|
||||
750
src/modules/assets/services/work-order.service.ts
Normal file
750
src/modules/assets/services/work-order.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user