From 5f9c30d26836e7f85a2815df8b2344e533186572 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 05:41:54 -0600 Subject: [PATCH] [MAE-015] feat: Implement assets module backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../assets/controllers/asset.controller.ts | 360 +++++++++ .../assets/controllers/fuel-log.controller.ts | 118 +++ src/modules/assets/controllers/index.ts | 8 + .../controllers/work-order.controller.ts | 418 ++++++++++ .../entities/asset-assignment.entity.ts | 134 ++++ .../assets/entities/asset-category.entity.ts | 95 +++ .../assets/entities/asset-cost.entity.ts | 132 +++ src/modules/assets/entities/asset.entity.ts | 284 +++++++ .../assets/entities/fuel-log.entity.ts | 113 +++ src/modules/assets/entities/index.ts | 14 + .../entities/maintenance-history.entity.ts | 108 +++ .../entities/maintenance-plan.entity.ts | 150 ++++ .../assets/entities/work-order-part.entity.ts | 86 ++ .../assets/entities/work-order.entity.ts | 263 ++++++ src/modules/assets/index.ts | 22 + src/modules/assets/services/asset.service.ts | 511 ++++++++++++ .../assets/services/fuel-log.service.ts | 232 ++++++ src/modules/assets/services/index.ts | 8 + .../assets/services/work-order.service.ts | 750 ++++++++++++++++++ 19 files changed, 3806 insertions(+) create mode 100644 src/modules/assets/controllers/asset.controller.ts create mode 100644 src/modules/assets/controllers/fuel-log.controller.ts create mode 100644 src/modules/assets/controllers/index.ts create mode 100644 src/modules/assets/controllers/work-order.controller.ts create mode 100644 src/modules/assets/entities/asset-assignment.entity.ts create mode 100644 src/modules/assets/entities/asset-category.entity.ts create mode 100644 src/modules/assets/entities/asset-cost.entity.ts create mode 100644 src/modules/assets/entities/asset.entity.ts create mode 100644 src/modules/assets/entities/fuel-log.entity.ts create mode 100644 src/modules/assets/entities/index.ts create mode 100644 src/modules/assets/entities/maintenance-history.entity.ts create mode 100644 src/modules/assets/entities/maintenance-plan.entity.ts create mode 100644 src/modules/assets/entities/work-order-part.entity.ts create mode 100644 src/modules/assets/entities/work-order.entity.ts create mode 100644 src/modules/assets/index.ts create mode 100644 src/modules/assets/services/asset.service.ts create mode 100644 src/modules/assets/services/fuel-log.service.ts create mode 100644 src/modules/assets/services/index.ts create mode 100644 src/modules/assets/services/work-order.service.ts diff --git a/src/modules/assets/controllers/asset.controller.ts b/src/modules/assets/controllers/asset.controller.ts new file mode 100644 index 0000000..f212ac6 --- /dev/null +++ b/src/modules/assets/controllers/asset.controller.ts @@ -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; +} diff --git a/src/modules/assets/controllers/fuel-log.controller.ts b/src/modules/assets/controllers/fuel-log.controller.ts new file mode 100644 index 0000000..e80f4f6 --- /dev/null +++ b/src/modules/assets/controllers/fuel-log.controller.ts @@ -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; +} diff --git a/src/modules/assets/controllers/index.ts b/src/modules/assets/controllers/index.ts new file mode 100644 index 0000000..a5b4cd6 --- /dev/null +++ b/src/modules/assets/controllers/index.ts @@ -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'; diff --git a/src/modules/assets/controllers/work-order.controller.ts b/src/modules/assets/controllers/work-order.controller.ts new file mode 100644 index 0000000..e475e16 --- /dev/null +++ b/src/modules/assets/controllers/work-order.controller.ts @@ -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; +} diff --git a/src/modules/assets/entities/asset-assignment.entity.ts b/src/modules/assets/entities/asset-assignment.entity.ts new file mode 100644 index 0000000..890fddf --- /dev/null +++ b/src/modules/assets/entities/asset-assignment.entity.ts @@ -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; + + // 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; +} diff --git a/src/modules/assets/entities/asset-category.entity.ts b/src/modules/assets/entities/asset-category.entity.ts new file mode 100644 index 0000000..275fa30 --- /dev/null +++ b/src/modules/assets/entities/asset-category.entity.ts @@ -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; + + // 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; +} diff --git a/src/modules/assets/entities/asset-cost.entity.ts b/src/modules/assets/entities/asset-cost.entity.ts new file mode 100644 index 0000000..722a9c1 --- /dev/null +++ b/src/modules/assets/entities/asset-cost.entity.ts @@ -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; + + // 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; +} diff --git a/src/modules/assets/entities/asset.entity.ts b/src/modules/assets/entities/asset.entity.ts new file mode 100644 index 0000000..98b54d1 --- /dev/null +++ b/src/modules/assets/entities/asset.entity.ts @@ -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; + + // 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; + + // 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; +} diff --git a/src/modules/assets/entities/fuel-log.entity.ts b/src/modules/assets/entities/fuel-log.entity.ts new file mode 100644 index 0000000..873c51d --- /dev/null +++ b/src/modules/assets/entities/fuel-log.entity.ts @@ -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; +} diff --git a/src/modules/assets/entities/index.ts b/src/modules/assets/entities/index.ts new file mode 100644 index 0000000..5d50d02 --- /dev/null +++ b/src/modules/assets/entities/index.ts @@ -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'; diff --git a/src/modules/assets/entities/maintenance-history.entity.ts b/src/modules/assets/entities/maintenance-history.entity.ts new file mode 100644 index 0000000..1c222a9 --- /dev/null +++ b/src/modules/assets/entities/maintenance-history.entity.ts @@ -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; +} diff --git a/src/modules/assets/entities/maintenance-plan.entity.ts b/src/modules/assets/entities/maintenance-plan.entity.ts new file mode 100644 index 0000000..fefd987 --- /dev/null +++ b/src/modules/assets/entities/maintenance-plan.entity.ts @@ -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[]; + + // 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[]; + + @Column({ name: 'required_tools', type: 'jsonb', nullable: true }) + requiredTools?: Record[]; + + @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; + + // 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; +} diff --git a/src/modules/assets/entities/work-order-part.entity.ts b/src/modules/assets/entities/work-order-part.entity.ts new file mode 100644 index 0000000..65a39bc --- /dev/null +++ b/src/modules/assets/entities/work-order-part.entity.ts @@ -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; +} diff --git a/src/modules/assets/entities/work-order.entity.ts b/src/modules/assets/entities/work-order.entity.ts new file mode 100644 index 0000000..b3b6b85 --- /dev/null +++ b/src/modules/assets/entities/work-order.entity.ts @@ -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[]; + + // 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; + + // 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; +} diff --git a/src/modules/assets/index.ts b/src/modules/assets/index.ts new file mode 100644 index 0000000..5860b37 --- /dev/null +++ b/src/modules/assets/index.ts @@ -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'; diff --git a/src/modules/assets/services/asset.service.ts b/src/modules/assets/services/asset.service.ts new file mode 100644 index 0000000..1b5abc8 --- /dev/null +++ b/src/modules/assets/services/asset.service.ts @@ -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; + 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 { + 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 { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class AssetService { + private assetRepository: Repository; + private categoryRepository: Repository; + private assignmentRepository: Repository; + + 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 { + // 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 { + return this.assetRepository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + /** + * Find asset by code + */ + async findByCode(tenantId: string, code: string): Promise { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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> { + 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 { + 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, userId?: string): Promise { + const category = this.categoryRepository.create({ + tenantId, + ...data, + createdBy: userId, + }); + return this.categoryRepository.save(category); + } + + /** + * Get all categories + */ + async getCategories(tenantId: string): Promise { + 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; + byType: Record; + 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 = {}; + byStatusRaw.forEach((row: any) => { + byStatus[row.status] = parseInt(row.count, 10); + }); + + const byType: Record = {}; + 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 { + 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 { + 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(); + } +} diff --git a/src/modules/assets/services/fuel-log.service.ts b/src/modules/assets/services/fuel-log.service.ts new file mode 100644 index 0000000..b401ed2 --- /dev/null +++ b/src/modules/assets/services/fuel-log.service.ts @@ -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; + private assetRepository: Repository; + + 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 { + // 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 { + 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 { + 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 { + const result = await this.fuelLogRepository.delete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/assets/services/index.ts b/src/modules/assets/services/index.ts new file mode 100644 index 0000000..268a963 --- /dev/null +++ b/src/modules/assets/services/index.ts @@ -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'; diff --git a/src/modules/assets/services/work-order.service.ts b/src/modules/assets/services/work-order.service.ts new file mode 100644 index 0000000..15391f2 --- /dev/null +++ b/src/modules/assets/services/work-order.service.ts @@ -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[]; + 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[]; + 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; + private partRepository: Repository; + private historyRepository: Repository; + private planRepository: Repository; + private assetRepository: Repository; + + 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 { + 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 { + // 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 { + return this.workOrderRepository.findOne({ + where: { id, tenantId }, + relations: ['asset', 'partsUsed'], + }); + } + + /** + * Find work order by number + */ + async findByNumber(tenantId: string, workOrderNumber: string): Promise { + 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 { + 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.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 { + return this.update(tenantId, id, { status: WorkOrderStatus.IN_PROGRESS }, userId); + } + + /** + * Complete work order + */ + async complete(tenantId: string, id: string, dto: CompleteWorkOrderDto, userId?: string): Promise { + 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 { + 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 { + 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; + byType: Record; + 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 = {}; + byStatusRaw.forEach((row: any) => { + byStatus[row.status] = parseInt(row.count, 10); + }); + + const byType: Record = {}; + 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> { + const workOrders = await this.workOrderRepository.find({ + where: { tenantId, deletedAt: null }, + relations: ['asset'], + order: { requestedDate: 'DESC' }, + }); + + const grouped: Record = { + [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 { + 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 { + 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 { + 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 { + 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 { + return this.partRepository.find({ + where: { tenantId, workOrderId }, + order: { createdAt: 'ASC' }, + }); + } + + /** + * Update a part + */ + async updatePart(tenantId: string, partId: string, dto: Partial, userId?: string): Promise { + 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 { + 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 { + 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 { + 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, userId?: string): Promise { + 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 { + 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; + } +}