[MAE-015] feat: Implement assets module backend
Complete implementation of the Assets/Machinery/Maintenance module: Entities (10): - AssetCategory: Hierarchical asset categorization with depreciation config - Asset: Main asset entity (machinery, vehicles, tools, equipment) - AssetAssignment: Asset-to-project assignments tracking - WorkOrder: Maintenance work orders with workflow - WorkOrderPart: Parts/materials used in work orders - MaintenancePlan: Preventive maintenance plans - MaintenanceHistory: Historical maintenance records - FuelLog: Fuel consumption tracking with efficiency calculation - AssetCost: TCO (Total Cost of Ownership) tracking Services (3): - AssetService: CRUD, assignments, categories, statistics - WorkOrderService: CRUD, workflow (start/hold/resume/complete/cancel), parts - FuelLogService: CRUD, efficiency calculation, statistics Controllers (3): - AssetController: REST API for assets, assignments, categories - WorkOrderController: REST API for work orders, workflow, plans - FuelLogController: REST API for fuel logs, statistics Features: - Multi-tenant support with tenant_id - Complete workflow for work orders (draft→scheduled→in_progress→completed) - Automatic efficiency calculation for fuel consumption - Asset assignment history tracking - Maintenance plan generation - TCO tracking by cost type Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bdf8c878e8
commit
5f9c30d268
360
src/modules/assets/controllers/asset.controller.ts
Normal file
360
src/modules/assets/controllers/asset.controller.ts
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* AssetController - Controlador de Activos
|
||||||
|
*
|
||||||
|
* Endpoints para gestión de activos fijos, maquinaria y equipos.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AssetService } from '../services';
|
||||||
|
|
||||||
|
export function createAssetController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
const service = new AssetService(dataSource);
|
||||||
|
|
||||||
|
// ==================== CRUD DE ACTIVOS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /
|
||||||
|
* Lista activos con filtros y paginación
|
||||||
|
*/
|
||||||
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
assetType: req.query.assetType as any,
|
||||||
|
status: req.query.status as any,
|
||||||
|
ownershipType: req.query.ownershipType as any,
|
||||||
|
categoryId: req.query.categoryId as string,
|
||||||
|
projectId: req.query.projectId as string,
|
||||||
|
search: req.query.search as string,
|
||||||
|
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: parseInt(req.query.limit as string) || 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.findAll(tenantId, filters, pagination);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /statistics
|
||||||
|
* Obtiene estadísticas de activos
|
||||||
|
*/
|
||||||
|
router.get('/statistics', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const stats = await service.getStatistics(tenantId);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /maintenance-due
|
||||||
|
* Lista activos que requieren mantenimiento
|
||||||
|
*/
|
||||||
|
router.get('/maintenance-due', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const assets = await service.getAssetsNeedingMaintenance(tenantId);
|
||||||
|
res.json(assets);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /search
|
||||||
|
* Búsqueda de activos para autocomplete
|
||||||
|
*/
|
||||||
|
router.get('/search', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const query = req.query.q as string;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return res.status(400).json({ error: 'Se requiere parámetro de búsqueda (q)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = await service.search(tenantId, query, limit);
|
||||||
|
res.json(assets);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /by-code/:code
|
||||||
|
* Obtiene un activo por código
|
||||||
|
*/
|
||||||
|
router.get('/by-code/:code', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const asset = await service.findByCode(tenantId, req.params.code);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id
|
||||||
|
* Obtiene un activo por ID
|
||||||
|
*/
|
||||||
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const asset = await service.findById(tenantId, req.params.id);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /
|
||||||
|
* Crea un nuevo activo
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const asset = await service.create(tenantId, req.body, userId);
|
||||||
|
res.status(201).json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /:id
|
||||||
|
* Actualiza un activo
|
||||||
|
*/
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const asset = await service.update(tenantId, req.params.id, req.body, userId);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /:id/status
|
||||||
|
* Actualiza el estado de un activo
|
||||||
|
*/
|
||||||
|
router.patch('/:id/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return res.status(400).json({ error: 'Se requiere el nuevo estado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await service.updateStatus(tenantId, req.params.id, status, userId);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /:id/usage
|
||||||
|
* Actualiza métricas de uso (horas/kilómetros)
|
||||||
|
*/
|
||||||
|
router.patch('/:id/usage', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { hours, kilometers } = req.body;
|
||||||
|
|
||||||
|
const asset = await service.updateUsage(tenantId, req.params.id, hours, kilometers, userId);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:id
|
||||||
|
* Elimina un activo (soft delete)
|
||||||
|
*/
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const deleted = await service.delete(tenantId, req.params.id, userId);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Activo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== ASIGNACIONES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id/assignment
|
||||||
|
* Obtiene la asignación actual de un activo
|
||||||
|
*/
|
||||||
|
router.get('/:id/assignment', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const assignment = await service.getCurrentAssignment(tenantId, req.params.id);
|
||||||
|
res.json(assignment);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id/assignments
|
||||||
|
* Obtiene el historial de asignaciones de un activo
|
||||||
|
*/
|
||||||
|
router.get('/:id/assignments', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: parseInt(req.query.limit as string) || 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const history = await service.getAssignmentHistory(tenantId, req.params.id, pagination);
|
||||||
|
res.json(history);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/assign
|
||||||
|
* Asigna un activo a un proyecto
|
||||||
|
*/
|
||||||
|
router.post('/:id/assign', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
...req.body,
|
||||||
|
assetId: req.params.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignment = await service.assignToProject(tenantId, dto, userId);
|
||||||
|
res.status(201).json(assignment);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/return
|
||||||
|
* Retorna un activo de un proyecto
|
||||||
|
*/
|
||||||
|
router.post('/:id/return', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { endDate } = req.body;
|
||||||
|
|
||||||
|
const result = await service.returnFromProject(
|
||||||
|
tenantId,
|
||||||
|
req.params.id,
|
||||||
|
endDate ? new Date(endDate) : new Date(),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(400).json({ error: 'No hay asignación activa para este activo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Activo retornado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== CATEGORÍAS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /categories
|
||||||
|
* Lista categorías de activos
|
||||||
|
*/
|
||||||
|
router.get('/categories/list', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const categories = await service.getCategories(tenantId);
|
||||||
|
res.json(categories);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /categories
|
||||||
|
* Crea una nueva categoría
|
||||||
|
*/
|
||||||
|
router.post('/categories', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const category = await service.createCategory(tenantId, req.body, userId);
|
||||||
|
res.status(201).json(category);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
118
src/modules/assets/controllers/fuel-log.controller.ts
Normal file
118
src/modules/assets/controllers/fuel-log.controller.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* FuelLogController - Controlador de Registro de Combustible
|
||||||
|
*
|
||||||
|
* Endpoints para gestión de cargas de combustible y rendimiento.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { FuelLogService } from '../services';
|
||||||
|
|
||||||
|
export function createFuelLogController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
const service = new FuelLogService(dataSource);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /
|
||||||
|
* Lista registros de combustible con filtros
|
||||||
|
*/
|
||||||
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
assetId: req.query.assetId as string,
|
||||||
|
projectId: req.query.projectId as string,
|
||||||
|
fuelType: req.query.fuelType as string,
|
||||||
|
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||||
|
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: parseInt(req.query.limit as string) || 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.findAll(tenantId, filters, pagination);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /statistics/:assetId
|
||||||
|
* Obtiene estadísticas de combustible para un activo
|
||||||
|
*/
|
||||||
|
router.get('/statistics/:assetId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const fromDate = req.query.fromDate ? new Date(req.query.fromDate as string) : undefined;
|
||||||
|
const toDate = req.query.toDate ? new Date(req.query.toDate as string) : undefined;
|
||||||
|
|
||||||
|
const stats = await service.getAssetStatistics(tenantId, req.params.assetId, fromDate, toDate);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id
|
||||||
|
* Obtiene un registro por ID
|
||||||
|
*/
|
||||||
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const fuelLog = await service.findById(tenantId, req.params.id);
|
||||||
|
if (!fuelLog) {
|
||||||
|
return res.status(404).json({ error: 'Registro de combustible no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(fuelLog);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /
|
||||||
|
* Crea un nuevo registro de combustible
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const fuelLog = await service.create(tenantId, req.body, userId);
|
||||||
|
res.status(201).json(fuelLog);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:id
|
||||||
|
* Elimina un registro de combustible
|
||||||
|
*/
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const deleted = await service.delete(tenantId, req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Registro de combustible no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
8
src/modules/assets/controllers/index.ts
Normal file
8
src/modules/assets/controllers/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Assets Controllers Index
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './asset.controller';
|
||||||
|
export * from './work-order.controller';
|
||||||
|
export * from './fuel-log.controller';
|
||||||
418
src/modules/assets/controllers/work-order.controller.ts
Normal file
418
src/modules/assets/controllers/work-order.controller.ts
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* WorkOrderController - Controlador de Órdenes de Trabajo
|
||||||
|
*
|
||||||
|
* Endpoints para gestión de órdenes de trabajo de mantenimiento.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { WorkOrderService } from '../services';
|
||||||
|
|
||||||
|
export function createWorkOrderController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
const service = new WorkOrderService(dataSource);
|
||||||
|
|
||||||
|
// ==================== CRUD DE ÓRDENES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /
|
||||||
|
* Lista órdenes de trabajo con filtros y paginación
|
||||||
|
*/
|
||||||
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
assetId: req.query.assetId as string,
|
||||||
|
status: req.query.status as any,
|
||||||
|
priority: req.query.priority as any,
|
||||||
|
maintenanceType: req.query.maintenanceType as any,
|
||||||
|
technicianId: req.query.technicianId as string,
|
||||||
|
projectId: req.query.projectId as string,
|
||||||
|
dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined,
|
||||||
|
dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined,
|
||||||
|
search: req.query.search as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: parseInt(req.query.limit as string) || 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.findAll(tenantId, filters, pagination);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /statistics
|
||||||
|
* Obtiene estadísticas de órdenes de trabajo
|
||||||
|
*/
|
||||||
|
router.get('/statistics', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const stats = await service.getStatistics(tenantId);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /by-status
|
||||||
|
* Obtiene órdenes agrupadas por estado
|
||||||
|
*/
|
||||||
|
router.get('/by-status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const grouped = await service.getByStatus(tenantId);
|
||||||
|
res.json(grouped);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /by-number/:orderNumber
|
||||||
|
* Obtiene una orden por número
|
||||||
|
*/
|
||||||
|
router.get('/by-number/:orderNumber', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const workOrder = await service.findByNumber(tenantId, req.params.orderNumber);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /overdue
|
||||||
|
* Lista órdenes vencidas
|
||||||
|
*/
|
||||||
|
router.get('/overdue', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const orders = await service.getOverdue(tenantId);
|
||||||
|
res.json(orders);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id
|
||||||
|
* Obtiene una orden de trabajo por ID
|
||||||
|
*/
|
||||||
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const workOrder = await service.findById(tenantId, req.params.id);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /
|
||||||
|
* Crea una nueva orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const workOrder = await service.create(tenantId, req.body, userId);
|
||||||
|
res.status(201).json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /:id
|
||||||
|
* Actualiza una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const workOrder = await service.update(tenantId, req.params.id, req.body, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:id
|
||||||
|
* Elimina una orden de trabajo (soft delete)
|
||||||
|
*/
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const deleted = await service.delete(tenantId, req.params.id, userId);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== WORKFLOW ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/start
|
||||||
|
* Inicia una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/:id/start', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const workOrder = await service.start(tenantId, req.params.id, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/hold
|
||||||
|
* Pone en espera una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/:id/hold', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
const workOrder = await service.hold(tenantId, req.params.id, reason, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/resume
|
||||||
|
* Reanuda una orden de trabajo en espera
|
||||||
|
*/
|
||||||
|
router.post('/:id/resume', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const workOrder = await service.resume(tenantId, req.params.id, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/complete
|
||||||
|
* Completa una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/:id/complete', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const workOrder = await service.complete(tenantId, req.params.id, req.body, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/cancel
|
||||||
|
* Cancela una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/:id/cancel', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
const workOrder = await service.cancel(tenantId, req.params.id, reason, userId);
|
||||||
|
if (!workOrder) {
|
||||||
|
return res.status(404).json({ error: 'Orden de trabajo no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(workOrder);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== PARTES/REFACCIONES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id/parts
|
||||||
|
* Lista partes usadas en una orden de trabajo
|
||||||
|
*/
|
||||||
|
router.get('/:id/parts', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
const parts = await service.getParts(tenantId, req.params.id);
|
||||||
|
res.json(parts);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/parts
|
||||||
|
* Agrega una parte a la orden de trabajo
|
||||||
|
*/
|
||||||
|
router.post('/:id/parts', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const part = await service.addPart(tenantId, req.params.id, req.body, userId);
|
||||||
|
res.status(201).json(part);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /:id/parts/:partId
|
||||||
|
* Actualiza una parte de la orden
|
||||||
|
*/
|
||||||
|
router.put('/:id/parts/:partId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const part = await service.updatePart(tenantId, req.params.partId, req.body, userId);
|
||||||
|
if (!part) {
|
||||||
|
return res.status(404).json({ error: 'Parte no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(part);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:id/parts/:partId
|
||||||
|
* Elimina una parte de la orden
|
||||||
|
*/
|
||||||
|
router.delete('/:id/parts/:partId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const deleted = await service.removePart(tenantId, req.params.partId, userId);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Parte no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== PLANES DE MANTENIMIENTO ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /plans
|
||||||
|
* Lista planes de mantenimiento
|
||||||
|
*/
|
||||||
|
router.get('/plans/list', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const assetId = req.query.assetId as string;
|
||||||
|
|
||||||
|
const plans = await service.getMaintenancePlans(tenantId, assetId);
|
||||||
|
res.json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /plans
|
||||||
|
* Crea un plan de mantenimiento
|
||||||
|
*/
|
||||||
|
router.post('/plans', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const plan = await service.createMaintenancePlan(tenantId, req.body, userId);
|
||||||
|
res.status(201).json(plan);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /plans/:planId/generate
|
||||||
|
* Genera órdenes de trabajo desde un plan
|
||||||
|
*/
|
||||||
|
router.post('/plans/:planId/generate', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
|
||||||
|
const orders = await service.generateFromPlan(tenantId, req.params.planId, userId);
|
||||||
|
res.status(201).json(orders);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
134
src/modules/assets/entities/asset-assignment.entity.ts
Normal file
134
src/modules/assets/entities/asset-assignment.entity.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* AssetAssignment Entity - Asignaciones de Activos a Proyectos
|
||||||
|
*
|
||||||
|
* Registro de movimientos de activos entre obras/proyectos.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
|
||||||
|
@Entity('asset_assignments', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'projectId'])
|
||||||
|
@Index(['tenantId', 'isCurrent'])
|
||||||
|
export class AssetAssignment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Activo y proyecto
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset, (asset) => asset.assignments)
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset!: Asset;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', type: 'uuid' })
|
||||||
|
projectId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_code', length: 50, nullable: true })
|
||||||
|
projectCode?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_name', length: 255, nullable: true })
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
// Periodo de asignacion
|
||||||
|
@Column({ name: 'start_date', type: 'date' })
|
||||||
|
startDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'end_date', type: 'date', nullable: true })
|
||||||
|
endDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'is_current', type: 'boolean', default: true })
|
||||||
|
isCurrent!: boolean;
|
||||||
|
|
||||||
|
// Ubicacion especifica en obra
|
||||||
|
@Column({ name: 'location_in_project', length: 255, nullable: true })
|
||||||
|
locationInProject?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
// Operador asignado
|
||||||
|
@Column({ name: 'operator_id', type: 'uuid', nullable: true })
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'operator_name', length: 255, nullable: true })
|
||||||
|
operatorName?: string;
|
||||||
|
|
||||||
|
// Responsable
|
||||||
|
@Column({ name: 'responsible_id', type: 'uuid', nullable: true })
|
||||||
|
responsibleId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'responsible_name', length: 255, nullable: true })
|
||||||
|
responsibleName?: string;
|
||||||
|
|
||||||
|
// Metricas al inicio/fin
|
||||||
|
@Column({ name: 'hours_at_start', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursAtStart?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hours_at_end', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursAtEnd?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'kilometers_at_start', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersAtStart?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'kilometers_at_end', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersAtEnd?: number;
|
||||||
|
|
||||||
|
// Tarifas
|
||||||
|
@Column({ name: 'daily_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
dailyRate?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hourly_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
hourlyRate?: number;
|
||||||
|
|
||||||
|
// Razon de transferencia
|
||||||
|
@Column({ name: 'transfer_reason', type: 'text', nullable: true })
|
||||||
|
transferReason?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'transfer_notes', type: 'text', nullable: true })
|
||||||
|
transferNotes?: string;
|
||||||
|
|
||||||
|
// Documento de entrega
|
||||||
|
@Column({ name: 'delivery_document_url', length: 500, nullable: true })
|
||||||
|
deliveryDocumentUrl?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'return_document_url', length: 500, nullable: true })
|
||||||
|
returnDocumentUrl?: string;
|
||||||
|
|
||||||
|
// Metadatos
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
95
src/modules/assets/entities/asset-category.entity.ts
Normal file
95
src/modules/assets/entities/asset-category.entity.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* AssetCategory Entity - Categorias de Activos
|
||||||
|
*
|
||||||
|
* Clasificacion jerarquica de activos con configuracion de depreciacion.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('asset_categories', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'code'], { unique: true })
|
||||||
|
@Index(['tenantId', 'parentId'])
|
||||||
|
export class AssetCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Informacion basica
|
||||||
|
@Column({ length: 20 })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Jerarquia
|
||||||
|
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetCategory, (cat) => cat.children, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent?: AssetCategory;
|
||||||
|
|
||||||
|
@OneToMany(() => AssetCategory, (cat) => cat.parent)
|
||||||
|
children?: AssetCategory[];
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 1 })
|
||||||
|
level!: number;
|
||||||
|
|
||||||
|
// Configuracion de depreciacion
|
||||||
|
@Column({ name: 'useful_life_years', type: 'int', nullable: true })
|
||||||
|
usefulLifeYears?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'depreciation_method', length: 50, nullable: true })
|
||||||
|
depreciationMethod?: string; // straight_line, declining_balance, units_of_production
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'salvage_value_percentage',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
salvageValuePercentage?: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
// Metadatos
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
132
src/modules/assets/entities/asset-cost.entity.ts
Normal file
132
src/modules/assets/entities/asset-cost.entity.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* AssetCost Entity - Costos de Activos (TCO)
|
||||||
|
*
|
||||||
|
* Registro de costos para calculo de TCO (Total Cost of Ownership).
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
|
||||||
|
export type CostType =
|
||||||
|
| 'maintenance'
|
||||||
|
| 'repair'
|
||||||
|
| 'fuel'
|
||||||
|
| 'insurance'
|
||||||
|
| 'tax'
|
||||||
|
| 'depreciation'
|
||||||
|
| 'operator'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
@Entity('asset_costs', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'periodStart', 'periodEnd'])
|
||||||
|
@Index(['tenantId', 'costType'])
|
||||||
|
@Index(['tenantId', 'projectId'])
|
||||||
|
export class AssetCost {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Activo
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset)
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset!: Asset;
|
||||||
|
|
||||||
|
// Periodo
|
||||||
|
@Column({ name: 'period_start', type: 'date' })
|
||||||
|
periodStart!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date' })
|
||||||
|
periodEnd!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'fiscal_year', type: 'int' })
|
||||||
|
fiscalYear!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'fiscal_month', type: 'int' })
|
||||||
|
fiscalMonth!: number;
|
||||||
|
|
||||||
|
// Proyecto (si aplica)
|
||||||
|
@Column({ name: 'project_id', type: 'uuid', nullable: true })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_code', length: 50, nullable: true })
|
||||||
|
projectCode?: string;
|
||||||
|
|
||||||
|
// Tipo de costo
|
||||||
|
@Column({
|
||||||
|
name: 'cost_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['maintenance', 'repair', 'fuel', 'insurance', 'tax', 'depreciation', 'operator', 'other'],
|
||||||
|
enumName: 'cost_type',
|
||||||
|
})
|
||||||
|
costType!: CostType;
|
||||||
|
|
||||||
|
// Descripcion
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'reference_document', length: 100, nullable: true })
|
||||||
|
referenceDocument?: string;
|
||||||
|
|
||||||
|
// Monto
|
||||||
|
@Column({ type: 'decimal', precision: 18, scale: 2 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ length: 3, default: 'MXN' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
// Uso asociado
|
||||||
|
@Column({ name: 'hours_in_period', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursInPeriod?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'kilometers_in_period', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersInPeriod?: number;
|
||||||
|
|
||||||
|
// Calculo de tarifa
|
||||||
|
@Column({ name: 'cost_per_hour', type: 'decimal', precision: 18, scale: 4, nullable: true })
|
||||||
|
costPerHour?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'cost_per_kilometer', type: 'decimal', precision: 18, scale: 4, nullable: true })
|
||||||
|
costPerKilometer?: number;
|
||||||
|
|
||||||
|
// Origen
|
||||||
|
@Column({ name: 'source_module', length: 50, nullable: true })
|
||||||
|
sourceModule?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_id', type: 'uuid', nullable: true })
|
||||||
|
sourceId?: string;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
284
src/modules/assets/entities/asset.entity.ts
Normal file
284
src/modules/assets/entities/asset.entity.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Asset Entity - Catalogo Principal de Activos
|
||||||
|
*
|
||||||
|
* Maquinaria, equipo, vehiculos y herramientas de construccion.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AssetCategory } from './asset-category.entity';
|
||||||
|
import { AssetAssignment } from './asset-assignment.entity';
|
||||||
|
import { MaintenanceHistory } from './maintenance-history.entity';
|
||||||
|
import { FuelLog } from './fuel-log.entity';
|
||||||
|
|
||||||
|
export type AssetType =
|
||||||
|
| 'heavy_machinery'
|
||||||
|
| 'light_equipment'
|
||||||
|
| 'vehicle'
|
||||||
|
| 'tool'
|
||||||
|
| 'computer'
|
||||||
|
| 'furniture'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export type AssetStatus =
|
||||||
|
| 'available'
|
||||||
|
| 'assigned'
|
||||||
|
| 'in_maintenance'
|
||||||
|
| 'in_transit'
|
||||||
|
| 'inactive'
|
||||||
|
| 'retired'
|
||||||
|
| 'sold';
|
||||||
|
|
||||||
|
export type OwnershipType = 'owned' | 'leased' | 'rented' | 'borrowed';
|
||||||
|
|
||||||
|
@Entity('assets', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'assetCode'], { unique: true })
|
||||||
|
@Index(['tenantId', 'assetType'])
|
||||||
|
@Index(['tenantId', 'status'])
|
||||||
|
@Index(['tenantId', 'categoryId'])
|
||||||
|
@Index(['tenantId', 'currentProjectId'])
|
||||||
|
@Index(['tenantId', 'serialNumber'])
|
||||||
|
export class Asset {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Column({ name: 'asset_code', length: 50 })
|
||||||
|
assetCode!: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Clasificacion
|
||||||
|
@Column({ name: 'category_id', type: 'uuid', nullable: true })
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetCategory, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category?: AssetCategory;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'asset_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['heavy_machinery', 'light_equipment', 'vehicle', 'tool', 'computer', 'furniture', 'other'],
|
||||||
|
enumName: 'asset_type',
|
||||||
|
})
|
||||||
|
assetType!: AssetType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['available', 'assigned', 'in_maintenance', 'in_transit', 'inactive', 'retired', 'sold'],
|
||||||
|
enumName: 'asset_status',
|
||||||
|
default: 'available',
|
||||||
|
})
|
||||||
|
status!: AssetStatus;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'ownership_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['owned', 'leased', 'rented', 'borrowed'],
|
||||||
|
enumName: 'ownership_type',
|
||||||
|
default: 'owned',
|
||||||
|
})
|
||||||
|
ownershipType!: OwnershipType;
|
||||||
|
|
||||||
|
// Especificaciones tecnicas
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
brand?: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
model?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', length: 100, nullable: true })
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'year_manufactured', type: 'int', nullable: true })
|
||||||
|
yearManufactured?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
specifications?: Record<string, any>;
|
||||||
|
|
||||||
|
// Capacidades
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
capacity?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'power_rating', length: 50, nullable: true })
|
||||||
|
powerRating?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'fuel_type', length: 50, nullable: true })
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'fuel_capacity', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
fuelCapacity?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'fuel_consumption_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
fuelConsumptionRate?: number;
|
||||||
|
|
||||||
|
// Metricas de uso
|
||||||
|
@Column({ name: 'current_hours', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
currentHours!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_kilometers', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
currentKilometers!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_usage_update', type: 'timestamptz', nullable: true })
|
||||||
|
lastUsageUpdate?: Date;
|
||||||
|
|
||||||
|
// Ubicacion actual
|
||||||
|
@Column({ name: 'current_project_id', type: 'uuid', nullable: true })
|
||||||
|
currentProjectId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'current_location_name', length: 255, nullable: true })
|
||||||
|
currentLocationName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'current_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
currentLatitude?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
currentLongitude?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_location_update', type: 'timestamptz', nullable: true })
|
||||||
|
lastLocationUpdate?: Date;
|
||||||
|
|
||||||
|
// Informacion financiera
|
||||||
|
@Column({ name: 'purchase_date', type: 'date', nullable: true })
|
||||||
|
purchaseDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'purchase_price', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
purchasePrice?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'purchase_currency', length: 3, default: 'MXN' })
|
||||||
|
purchaseCurrency!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'supplier_id', type: 'uuid', nullable: true })
|
||||||
|
supplierId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'invoice_number', length: 100, nullable: true })
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
// Depreciacion
|
||||||
|
@Column({ name: 'useful_life_years', type: 'int', nullable: true })
|
||||||
|
usefulLifeYears?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'salvage_value', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
salvageValue?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_book_value', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
currentBookValue?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'accumulated_depreciation', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
accumulatedDepreciation!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'depreciation_method', length: 50, nullable: true })
|
||||||
|
depreciationMethod?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_depreciation_date', type: 'date', nullable: true })
|
||||||
|
lastDepreciationDate?: Date;
|
||||||
|
|
||||||
|
// Arrendamiento (si aplica)
|
||||||
|
@Column({ name: 'lease_start_date', type: 'date', nullable: true })
|
||||||
|
leaseStartDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'lease_end_date', type: 'date', nullable: true })
|
||||||
|
leaseEndDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'lease_monthly_rate', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
leaseMonthlyRate?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'lease_contract_number', length: 100, nullable: true })
|
||||||
|
leaseContractNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'lessor_name', length: 255, nullable: true })
|
||||||
|
lessorName?: string;
|
||||||
|
|
||||||
|
// Seguro
|
||||||
|
@Column({ name: 'insurance_policy_number', length: 100, nullable: true })
|
||||||
|
insurancePolicyNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'insurance_company', length: 255, nullable: true })
|
||||||
|
insuranceCompany?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'insurance_expiry_date', type: 'date', nullable: true })
|
||||||
|
insuranceExpiryDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'insurance_coverage_amount', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
insuranceCoverageAmount?: number;
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
@Column({ name: 'photo_url', length: 500, nullable: true })
|
||||||
|
photoUrl?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'manual_url', length: 500, nullable: true })
|
||||||
|
manualUrl?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'registration_document_url', length: 500, nullable: true })
|
||||||
|
registrationDocumentUrl?: string;
|
||||||
|
|
||||||
|
// Proximo mantenimiento
|
||||||
|
@Column({ name: 'next_maintenance_date', type: 'date', nullable: true })
|
||||||
|
nextMaintenanceDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'next_maintenance_hours', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
nextMaintenanceHours?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'next_maintenance_kilometers', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
nextMaintenanceKilometers?: number;
|
||||||
|
|
||||||
|
// Operador asignado
|
||||||
|
@Column({ name: 'assigned_operator_id', type: 'uuid', nullable: true })
|
||||||
|
assignedOperatorId?: string;
|
||||||
|
|
||||||
|
// Notas y metadatos
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', array: true, nullable: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@OneToMany(() => AssetAssignment, (assignment) => assignment.asset)
|
||||||
|
assignments?: AssetAssignment[];
|
||||||
|
|
||||||
|
@OneToMany(() => MaintenanceHistory, (history) => history.asset)
|
||||||
|
maintenanceHistory?: MaintenanceHistory[];
|
||||||
|
|
||||||
|
@OneToMany(() => FuelLog, (log) => log.asset)
|
||||||
|
fuelLogs?: FuelLog[];
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
113
src/modules/assets/entities/fuel-log.entity.ts
Normal file
113
src/modules/assets/entities/fuel-log.entity.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* FuelLog Entity - Registro de Combustible
|
||||||
|
*
|
||||||
|
* Cargas de combustible y calculo de rendimiento.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
|
||||||
|
@Entity('fuel_logs', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'logDate'])
|
||||||
|
export class FuelLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Activo
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset, (asset) => asset.fuelLogs)
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset!: Asset;
|
||||||
|
|
||||||
|
// Fecha y ubicacion
|
||||||
|
@Column({ name: 'log_date', type: 'date' })
|
||||||
|
logDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'log_time', type: 'time', nullable: true })
|
||||||
|
logTime?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', type: 'uuid', nullable: true })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
// Combustible
|
||||||
|
@Column({ name: 'fuel_type', length: 50 })
|
||||||
|
fuelType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_liters', type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
quantityLiters!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_price', type: 'decimal', precision: 18, scale: 4 })
|
||||||
|
unitPrice!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2 })
|
||||||
|
totalCost!: number;
|
||||||
|
|
||||||
|
// Metricas al cargar
|
||||||
|
@Column({ name: 'odometer_reading', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
odometerReading?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hours_reading', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursReading?: number;
|
||||||
|
|
||||||
|
// Rendimiento calculado
|
||||||
|
@Column({ name: 'kilometers_since_last', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersSinceLast?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hours_since_last', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursSinceLast?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'liters_per_100km', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||||||
|
litersPer100km?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'liters_per_hour', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||||||
|
litersPerHour?: number;
|
||||||
|
|
||||||
|
// Proveedor
|
||||||
|
@Column({ name: 'vendor_name', length: 255, nullable: true })
|
||||||
|
vendorName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'invoice_number', length: 100, nullable: true })
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
// Operador
|
||||||
|
@Column({ name: 'operator_id', type: 'uuid', nullable: true })
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'operator_name', length: 255, nullable: true })
|
||||||
|
operatorName?: string;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
14
src/modules/assets/entities/index.ts
Normal file
14
src/modules/assets/entities/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Assets Entities Index
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './asset-category.entity';
|
||||||
|
export * from './asset.entity';
|
||||||
|
export * from './asset-assignment.entity';
|
||||||
|
export * from './maintenance-plan.entity';
|
||||||
|
export * from './maintenance-history.entity';
|
||||||
|
export * from './work-order.entity';
|
||||||
|
export * from './work-order-part.entity';
|
||||||
|
export * from './asset-cost.entity';
|
||||||
|
export * from './fuel-log.entity';
|
||||||
108
src/modules/assets/entities/maintenance-history.entity.ts
Normal file
108
src/modules/assets/entities/maintenance-history.entity.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* MaintenanceHistory Entity - Historial de Mantenimientos
|
||||||
|
*
|
||||||
|
* Registro historico de todos los mantenimientos realizados.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
import { WorkOrder } from './work-order.entity';
|
||||||
|
|
||||||
|
@Entity('maintenance_history', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'maintenanceDate'])
|
||||||
|
export class MaintenanceHistory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Activo
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset, (asset) => asset.maintenanceHistory)
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset!: Asset;
|
||||||
|
|
||||||
|
// Orden de trabajo (si aplica)
|
||||||
|
@Column({ name: 'work_order_id', type: 'uuid', nullable: true })
|
||||||
|
workOrderId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => WorkOrder, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'work_order_id' })
|
||||||
|
workOrder?: WorkOrder;
|
||||||
|
|
||||||
|
// Fecha y tipo
|
||||||
|
@Column({ name: 'maintenance_date', type: 'date' })
|
||||||
|
maintenanceDate!: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'maintenance_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
|
||||||
|
enumName: 'maintenance_type',
|
||||||
|
})
|
||||||
|
maintenanceType!: string;
|
||||||
|
|
||||||
|
// Descripcion
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'work_performed', type: 'text', nullable: true })
|
||||||
|
workPerformed?: string;
|
||||||
|
|
||||||
|
// Metricas al momento del mantenimiento
|
||||||
|
@Column({ name: 'hours_at_maintenance', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursAtMaintenance?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'kilometers_at_maintenance', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersAtMaintenance?: number;
|
||||||
|
|
||||||
|
// Costos
|
||||||
|
@Column({ name: 'labor_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
laborCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'parts_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
partsCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
totalCost!: number;
|
||||||
|
|
||||||
|
// Ejecutor
|
||||||
|
@Column({ name: 'performed_by_id', type: 'uuid', nullable: true })
|
||||||
|
performedById?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'performed_by_name', length: 255, nullable: true })
|
||||||
|
performedByName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'vendor_name', length: 255, nullable: true })
|
||||||
|
vendorName?: string;
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
documents?: string[];
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
150
src/modules/assets/entities/maintenance-plan.entity.ts
Normal file
150
src/modules/assets/entities/maintenance-plan.entity.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* MaintenancePlan Entity - Planes de Mantenimiento
|
||||||
|
*
|
||||||
|
* Planes de mantenimiento preventivo para activos.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
import { AssetCategory } from './asset-category.entity';
|
||||||
|
|
||||||
|
export type MaintenanceFrequency =
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'biweekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'quarterly'
|
||||||
|
| 'semiannual'
|
||||||
|
| 'annual'
|
||||||
|
| 'by_hours'
|
||||||
|
| 'by_kilometers';
|
||||||
|
|
||||||
|
@Entity('maintenance_plans', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'planCode'], { unique: true })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'categoryId'])
|
||||||
|
export class MaintenancePlan {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Column({ name: 'plan_code', length: 50 })
|
||||||
|
planCode!: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Aplica a
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid', nullable: true })
|
||||||
|
assetId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset?: Asset;
|
||||||
|
|
||||||
|
@Column({ name: 'category_id', type: 'uuid', nullable: true })
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetCategory, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category?: AssetCategory;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'asset_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['heavy_machinery', 'light_equipment', 'vehicle', 'tool', 'computer', 'furniture', 'other'],
|
||||||
|
enumName: 'asset_type',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
assetType?: string;
|
||||||
|
|
||||||
|
// Tipo y frecuencia
|
||||||
|
@Column({
|
||||||
|
name: 'maintenance_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
|
||||||
|
enumName: 'maintenance_type',
|
||||||
|
default: 'preventive',
|
||||||
|
})
|
||||||
|
maintenanceType!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual', 'by_hours', 'by_kilometers'],
|
||||||
|
enumName: 'maintenance_frequency',
|
||||||
|
})
|
||||||
|
frequency!: MaintenanceFrequency;
|
||||||
|
|
||||||
|
@Column({ name: 'frequency_value', type: 'int', nullable: true })
|
||||||
|
frequencyValue?: number;
|
||||||
|
|
||||||
|
// Actividades del plan
|
||||||
|
@Column({ type: 'jsonb' })
|
||||||
|
activities!: Record<string, any>[];
|
||||||
|
|
||||||
|
// Duracion estimada
|
||||||
|
@Column({ name: 'estimated_duration_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||||
|
estimatedDurationHours?: number;
|
||||||
|
|
||||||
|
// Recursos necesarios
|
||||||
|
@Column({ name: 'required_parts', type: 'jsonb', nullable: true })
|
||||||
|
requiredParts?: Record<string, any>[];
|
||||||
|
|
||||||
|
@Column({ name: 'required_tools', type: 'jsonb', nullable: true })
|
||||||
|
requiredTools?: Record<string, any>[];
|
||||||
|
|
||||||
|
@Column({ name: 'required_skills', type: 'varchar', array: true, nullable: true })
|
||||||
|
requiredSkills?: string[];
|
||||||
|
|
||||||
|
// Costos estimados
|
||||||
|
@Column({ name: 'estimated_labor_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
estimatedLaborCost?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'estimated_parts_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
estimatedPartsCost?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'estimated_total_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
estimatedTotalCost?: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
// Metadatos
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
86
src/modules/assets/entities/work-order-part.entity.ts
Normal file
86
src/modules/assets/entities/work-order-part.entity.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* WorkOrderPart Entity - Partes/Refacciones Utilizadas
|
||||||
|
*
|
||||||
|
* Partes consumidas en ordenes de trabajo de mantenimiento.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { WorkOrder } from './work-order.entity';
|
||||||
|
|
||||||
|
@Entity('work_order_parts', { schema: 'assets' })
|
||||||
|
@Index(['workOrderId'])
|
||||||
|
export class WorkOrderPart {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Orden de trabajo
|
||||||
|
@Column({ name: 'work_order_id', type: 'uuid' })
|
||||||
|
workOrderId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => WorkOrder, (wo) => wo.partsUsed, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'work_order_id' })
|
||||||
|
workOrder!: WorkOrder;
|
||||||
|
|
||||||
|
// Parte/Refaccion
|
||||||
|
@Column({ name: 'part_id', type: 'uuid', nullable: true })
|
||||||
|
partId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'part_code', length: 50, nullable: true })
|
||||||
|
partCode?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'part_name', length: 255 })
|
||||||
|
partName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'part_description', type: 'text', nullable: true })
|
||||||
|
partDescription?: string;
|
||||||
|
|
||||||
|
// Cantidades
|
||||||
|
@Column({ name: 'quantity_required', type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
quantityRequired!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_used', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
quantityUsed?: number;
|
||||||
|
|
||||||
|
// Costos
|
||||||
|
@Column({ name: 'unit_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
unitCost?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, nullable: true })
|
||||||
|
totalCost?: number;
|
||||||
|
|
||||||
|
// Origen
|
||||||
|
@Column({ name: 'from_inventory', type: 'boolean', default: false })
|
||||||
|
fromInventory!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'purchase_order_id', type: 'uuid', nullable: true })
|
||||||
|
purchaseOrderId?: string;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
263
src/modules/assets/entities/work-order.entity.ts
Normal file
263
src/modules/assets/entities/work-order.entity.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* WorkOrder Entity - Ordenes de Trabajo de Mantenimiento
|
||||||
|
*
|
||||||
|
* Registro de mantenimientos preventivos y correctivos.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Asset } from './asset.entity';
|
||||||
|
import { WorkOrderPart } from './work-order-part.entity';
|
||||||
|
|
||||||
|
export type MaintenanceType = 'preventive' | 'corrective' | 'predictive' | 'emergency';
|
||||||
|
export type WorkOrderStatus = 'draft' | 'scheduled' | 'in_progress' | 'on_hold' | 'completed' | 'cancelled';
|
||||||
|
export type WorkOrderPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
@Entity('work_orders', { schema: 'assets' })
|
||||||
|
@Index(['tenantId', 'workOrderNumber'], { unique: true })
|
||||||
|
@Index(['tenantId', 'assetId'])
|
||||||
|
@Index(['tenantId', 'status'])
|
||||||
|
@Index(['tenantId', 'scheduledStartDate'])
|
||||||
|
@Index(['tenantId', 'projectId'])
|
||||||
|
export class WorkOrder {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Column({ name: 'work_order_number', length: 50 })
|
||||||
|
workOrderNumber!: string;
|
||||||
|
|
||||||
|
// Activo
|
||||||
|
@Column({ name: 'asset_id', type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Asset)
|
||||||
|
@JoinColumn({ name: 'asset_id' })
|
||||||
|
asset!: Asset;
|
||||||
|
|
||||||
|
@Column({ name: 'asset_code', length: 50, nullable: true })
|
||||||
|
assetCode?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'asset_name', length: 255, nullable: true })
|
||||||
|
assetName?: string;
|
||||||
|
|
||||||
|
// Tipo y estado
|
||||||
|
@Column({
|
||||||
|
name: 'maintenance_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['preventive', 'corrective', 'predictive', 'emergency'],
|
||||||
|
enumName: 'maintenance_type',
|
||||||
|
})
|
||||||
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['draft', 'scheduled', 'in_progress', 'on_hold', 'completed', 'cancelled'],
|
||||||
|
enumName: 'work_order_status',
|
||||||
|
default: 'draft',
|
||||||
|
})
|
||||||
|
status!: WorkOrderStatus;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['low', 'medium', 'high', 'critical'],
|
||||||
|
enumName: 'work_order_priority',
|
||||||
|
default: 'medium',
|
||||||
|
})
|
||||||
|
priority!: WorkOrderPriority;
|
||||||
|
|
||||||
|
// Origen
|
||||||
|
@Column({ name: 'schedule_id', type: 'uuid', nullable: true })
|
||||||
|
scheduleId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'plan_id', type: 'uuid', nullable: true })
|
||||||
|
planId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_scheduled', type: 'boolean', default: false })
|
||||||
|
isScheduled!: boolean;
|
||||||
|
|
||||||
|
// Descripcion
|
||||||
|
@Column({ length: 255 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'problem_reported', type: 'text', nullable: true })
|
||||||
|
problemReported?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
diagnosis?: string;
|
||||||
|
|
||||||
|
// Ubicacion
|
||||||
|
@Column({ name: 'project_id', type: 'uuid', nullable: true })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_name', length: 255, nullable: true })
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'location_description', length: 255, nullable: true })
|
||||||
|
locationDescription?: string;
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
@Column({ name: 'requested_date', type: 'date' })
|
||||||
|
requestedDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_start_date', type: 'date', nullable: true })
|
||||||
|
scheduledStartDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_end_date', type: 'date', nullable: true })
|
||||||
|
scheduledEndDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_start_date', type: 'date', nullable: true })
|
||||||
|
actualStartDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_end_date', type: 'date', nullable: true })
|
||||||
|
actualEndDate?: Date;
|
||||||
|
|
||||||
|
// Metricas del equipo al momento
|
||||||
|
@Column({ name: 'hours_at_work_order', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
hoursAtWorkOrder?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'kilometers_at_work_order', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
kilometersAtWorkOrder?: number;
|
||||||
|
|
||||||
|
// Asignacion
|
||||||
|
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'assigned_to_name', length: 255, nullable: true })
|
||||||
|
assignedToName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'team_ids', type: 'uuid', array: true, nullable: true })
|
||||||
|
teamIds?: string[];
|
||||||
|
|
||||||
|
// Solicitante
|
||||||
|
@Column({ name: 'requested_by_id', type: 'uuid', nullable: true })
|
||||||
|
requestedById?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'requested_by_name', length: 255, nullable: true })
|
||||||
|
requestedByName?: string;
|
||||||
|
|
||||||
|
// Aprobacion
|
||||||
|
@Column({ name: 'approved_by_id', type: 'uuid', nullable: true })
|
||||||
|
approvedById?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||||
|
approvedAt?: Date;
|
||||||
|
|
||||||
|
// Trabajo realizado
|
||||||
|
@Column({ name: 'work_performed', type: 'text', nullable: true })
|
||||||
|
workPerformed?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
findings?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
recommendations?: string;
|
||||||
|
|
||||||
|
// Checklist de actividades
|
||||||
|
@Column({ name: 'activities_checklist', type: 'jsonb', nullable: true })
|
||||||
|
activitiesChecklist?: Record<string, any>[];
|
||||||
|
|
||||||
|
// Tiempos
|
||||||
|
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||||
|
estimatedHours?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||||
|
actualHours?: number;
|
||||||
|
|
||||||
|
// Costos
|
||||||
|
@Column({ name: 'labor_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
laborCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'parts_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
partsCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'external_service_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
externalServiceCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'other_costs', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
otherCosts!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 18, scale: 2, default: 0 })
|
||||||
|
totalCost!: number;
|
||||||
|
|
||||||
|
// Partes utilizadas
|
||||||
|
@Column({ name: 'parts_used_count', type: 'int', default: 0 })
|
||||||
|
partsUsedCount!: number;
|
||||||
|
|
||||||
|
@OneToMany(() => WorkOrderPart, (part) => part.workOrder, { cascade: true })
|
||||||
|
partsUsed?: WorkOrderPart[];
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
@Column({ name: 'photos_before', type: 'jsonb', nullable: true })
|
||||||
|
photosBefore?: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'photos_after', type: 'jsonb', nullable: true })
|
||||||
|
photosAfter?: string[];
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
documents?: string[];
|
||||||
|
|
||||||
|
// Firma de conformidad
|
||||||
|
@Column({ name: 'completed_by_id', type: 'uuid', nullable: true })
|
||||||
|
completedById?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_by_name', length: 255, nullable: true })
|
||||||
|
completedByName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_signature_url', length: 500, nullable: true })
|
||||||
|
completionSignatureUrl?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_notes', type: 'text', nullable: true })
|
||||||
|
completionNotes?: string;
|
||||||
|
|
||||||
|
// Seguimiento
|
||||||
|
@Column({ name: 'requires_followup', type: 'boolean', default: false })
|
||||||
|
requiresFollowup!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'followup_notes', type: 'text', nullable: true })
|
||||||
|
followupNotes?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'followup_work_order_id', type: 'uuid', nullable: true })
|
||||||
|
followupWorkOrderId?: string;
|
||||||
|
|
||||||
|
// Notas y metadatos
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
// Auditoria
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
22
src/modules/assets/index.ts
Normal file
22
src/modules/assets/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Assets Module - Gestión de Activos Fijos y Maquinaria
|
||||||
|
* ERP Construccion - MAE-015
|
||||||
|
*
|
||||||
|
* Este módulo incluye:
|
||||||
|
* - Gestión de activos fijos, maquinaria y equipos
|
||||||
|
* - Asignaciones de activos a proyectos
|
||||||
|
* - Órdenes de trabajo de mantenimiento
|
||||||
|
* - Planes de mantenimiento preventivo
|
||||||
|
* - Historial de mantenimiento
|
||||||
|
* - Registro de combustible
|
||||||
|
* - Costos de operación (TCO)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
export * from './entities';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export * from './services';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
export * from './controllers';
|
||||||
511
src/modules/assets/services/asset.service.ts
Normal file
511
src/modules/assets/services/asset.service.ts
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
/**
|
||||||
|
* Asset Service
|
||||||
|
* ERP Construccion - Modulo Activos (MAE-015)
|
||||||
|
*
|
||||||
|
* Logica de negocio para gestion de activos fijos y maquinaria.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, ILike } from 'typeorm';
|
||||||
|
import { Asset, AssetType, AssetStatus, OwnershipType } from '../entities/asset.entity';
|
||||||
|
import { AssetCategory } from '../entities/asset-category.entity';
|
||||||
|
import { AssetAssignment } from '../entities/asset-assignment.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateAssetDto {
|
||||||
|
assetCode: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
assetType: AssetType;
|
||||||
|
ownershipType?: OwnershipType;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
yearManufactured?: number;
|
||||||
|
specifications?: Record<string, any>;
|
||||||
|
capacity?: string;
|
||||||
|
powerRating?: string;
|
||||||
|
fuelType?: string;
|
||||||
|
fuelCapacity?: number;
|
||||||
|
purchaseDate?: Date;
|
||||||
|
purchasePrice?: number;
|
||||||
|
supplierId?: string;
|
||||||
|
usefulLifeYears?: number;
|
||||||
|
salvageValue?: number;
|
||||||
|
photoUrl?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAssetDto extends Partial<CreateAssetDto> {
|
||||||
|
status?: AssetStatus;
|
||||||
|
currentProjectId?: string;
|
||||||
|
currentLocationName?: string;
|
||||||
|
currentLatitude?: number;
|
||||||
|
currentLongitude?: number;
|
||||||
|
currentHours?: number;
|
||||||
|
currentKilometers?: number;
|
||||||
|
assignedOperatorId?: string;
|
||||||
|
lastLocationUpdate?: Date;
|
||||||
|
lastUsageUpdate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetFilters {
|
||||||
|
assetType?: AssetType;
|
||||||
|
status?: AssetStatus;
|
||||||
|
ownershipType?: OwnershipType;
|
||||||
|
categoryId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
search?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignAssetDto {
|
||||||
|
assetId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectCode?: string;
|
||||||
|
projectName?: string;
|
||||||
|
startDate: Date;
|
||||||
|
operatorId?: string;
|
||||||
|
operatorName?: string;
|
||||||
|
responsibleId?: string;
|
||||||
|
responsibleName?: string;
|
||||||
|
locationInProject?: string;
|
||||||
|
dailyRate?: number;
|
||||||
|
hourlyRate?: number;
|
||||||
|
transferReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetService {
|
||||||
|
private assetRepository: Repository<Asset>;
|
||||||
|
private categoryRepository: Repository<AssetCategory>;
|
||||||
|
private assignmentRepository: Repository<AssetAssignment>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.assetRepository = dataSource.getRepository(Asset);
|
||||||
|
this.categoryRepository = dataSource.getRepository(AssetCategory);
|
||||||
|
this.assignmentRepository = dataSource.getRepository(AssetAssignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ASSETS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new asset
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, dto: CreateAssetDto, userId?: string): Promise<Asset> {
|
||||||
|
// Check code uniqueness
|
||||||
|
const existing = await this.assetRepository.findOne({
|
||||||
|
where: { tenantId, assetCode: dto.assetCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Asset with code ${dto.assetCode} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = this.assetRepository.create({
|
||||||
|
tenantId,
|
||||||
|
...dto,
|
||||||
|
status: AssetStatus.AVAILABLE,
|
||||||
|
ownershipType: dto.ownershipType || OwnershipType.OWNED,
|
||||||
|
currentBookValue: dto.purchasePrice,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.assetRepository.save(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find asset by ID
|
||||||
|
*/
|
||||||
|
async findById(tenantId: string, id: string): Promise<Asset | null> {
|
||||||
|
return this.assetRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find asset by code
|
||||||
|
*/
|
||||||
|
async findByCode(tenantId: string, code: string): Promise<Asset | null> {
|
||||||
|
return this.assetRepository.findOne({
|
||||||
|
where: { tenantId, assetCode: code },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List assets with filters and pagination
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: AssetFilters = {},
|
||||||
|
pagination: PaginationOptions = { page: 1, limit: 20 }
|
||||||
|
): Promise<PaginatedResult<Asset>> {
|
||||||
|
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.leftJoinAndSelect('asset.category', 'category')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters.assetType) {
|
||||||
|
queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: filters.assetType });
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
queryBuilder.andWhere('asset.status = :status', { status: filters.status });
|
||||||
|
}
|
||||||
|
if (filters.ownershipType) {
|
||||||
|
queryBuilder.andWhere('asset.ownership_type = :ownershipType', { ownershipType: filters.ownershipType });
|
||||||
|
}
|
||||||
|
if (filters.categoryId) {
|
||||||
|
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: filters.categoryId });
|
||||||
|
}
|
||||||
|
if (filters.projectId) {
|
||||||
|
queryBuilder.andWhere('asset.current_project_id = :projectId', { projectId: filters.projectId });
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(asset.asset_code ILIKE :search OR asset.name ILIKE :search OR asset.serial_number ILIKE :search)',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.tags && filters.tags.length > 0) {
|
||||||
|
queryBuilder.andWhere('asset.tags && :tags', { tags: filters.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (pagination.page - 1) * pagination.limit;
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.orderBy('asset.name', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(pagination.limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update asset
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, id: string, dto: UpdateAssetDto, userId?: string): Promise<Asset | null> {
|
||||||
|
const asset = await this.findById(tenantId, id);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
// Update location timestamp if location changed
|
||||||
|
if (dto.currentLatitude !== undefined || dto.currentLongitude !== undefined) {
|
||||||
|
dto.lastLocationUpdate = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update usage timestamp if hours/km changed
|
||||||
|
if (dto.currentHours !== undefined || dto.currentKilometers !== undefined) {
|
||||||
|
dto.lastUsageUpdate = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(asset, dto, { updatedBy: userId });
|
||||||
|
return this.assetRepository.save(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update asset status
|
||||||
|
*/
|
||||||
|
async updateStatus(tenantId: string, id: string, status: AssetStatus, userId?: string): Promise<Asset | null> {
|
||||||
|
return this.update(tenantId, id, { status }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update asset usage metrics (hours, km)
|
||||||
|
*/
|
||||||
|
async updateUsage(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
hours?: number,
|
||||||
|
kilometers?: number,
|
||||||
|
userId?: string
|
||||||
|
): Promise<Asset | null> {
|
||||||
|
const asset = await this.findById(tenantId, id);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
if (hours !== undefined) {
|
||||||
|
asset.currentHours = hours;
|
||||||
|
}
|
||||||
|
if (kilometers !== undefined) {
|
||||||
|
asset.currentKilometers = kilometers;
|
||||||
|
}
|
||||||
|
asset.lastUsageUpdate = new Date();
|
||||||
|
asset.updatedBy = userId;
|
||||||
|
|
||||||
|
return this.assetRepository.save(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete asset
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string, userId?: string): Promise<boolean> {
|
||||||
|
const result = await this.assetRepository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{ deletedAt: new Date(), status: AssetStatus.RETIRED, updatedBy: userId }
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ASSIGNMENTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign asset to project
|
||||||
|
*/
|
||||||
|
async assignToProject(tenantId: string, dto: AssignAssetDto, userId?: string): Promise<AssetAssignment> {
|
||||||
|
// Close current assignment if exists
|
||||||
|
await this.assignmentRepository.update(
|
||||||
|
{ tenantId, assetId: dto.assetId, isCurrent: true },
|
||||||
|
{ isCurrent: false, endDate: new Date() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get asset for current metrics
|
||||||
|
const asset = await this.findById(tenantId, dto.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('Asset not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new assignment
|
||||||
|
const assignment = this.assignmentRepository.create({
|
||||||
|
tenantId,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
projectId: dto.projectId,
|
||||||
|
projectCode: dto.projectCode,
|
||||||
|
projectName: dto.projectName,
|
||||||
|
startDate: dto.startDate,
|
||||||
|
isCurrent: true,
|
||||||
|
operatorId: dto.operatorId,
|
||||||
|
operatorName: dto.operatorName,
|
||||||
|
responsibleId: dto.responsibleId,
|
||||||
|
responsibleName: dto.responsibleName,
|
||||||
|
locationInProject: dto.locationInProject,
|
||||||
|
hoursAtStart: asset.currentHours,
|
||||||
|
kilometersAtStart: asset.currentKilometers,
|
||||||
|
dailyRate: dto.dailyRate,
|
||||||
|
hourlyRate: dto.hourlyRate,
|
||||||
|
transferReason: dto.transferReason,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedAssignment = await this.assignmentRepository.save(assignment);
|
||||||
|
|
||||||
|
// Update asset with new project
|
||||||
|
await this.update(tenantId, dto.assetId, {
|
||||||
|
currentProjectId: dto.projectId,
|
||||||
|
status: AssetStatus.ASSIGNED,
|
||||||
|
assignedOperatorId: dto.operatorId,
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
return savedAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current assignment for asset
|
||||||
|
*/
|
||||||
|
async getCurrentAssignment(tenantId: string, assetId: string): Promise<AssetAssignment | null> {
|
||||||
|
return this.assignmentRepository.findOne({
|
||||||
|
where: { tenantId, assetId, isCurrent: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assignment history for asset
|
||||||
|
*/
|
||||||
|
async getAssignmentHistory(
|
||||||
|
tenantId: string,
|
||||||
|
assetId: string,
|
||||||
|
pagination: PaginationOptions = { page: 1, limit: 20 }
|
||||||
|
): Promise<PaginatedResult<AssetAssignment>> {
|
||||||
|
const skip = (pagination.page - 1) * pagination.limit;
|
||||||
|
|
||||||
|
const [data, total] = await this.assignmentRepository.findAndCount({
|
||||||
|
where: { tenantId, assetId },
|
||||||
|
order: { startDate: 'DESC' },
|
||||||
|
skip,
|
||||||
|
take: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return asset from project (close assignment)
|
||||||
|
*/
|
||||||
|
async returnFromProject(tenantId: string, assetId: string, endDate: Date, userId?: string): Promise<boolean> {
|
||||||
|
const assignment = await this.getCurrentAssignment(tenantId, assetId);
|
||||||
|
if (!assignment) return false;
|
||||||
|
|
||||||
|
const asset = await this.findById(tenantId, assetId);
|
||||||
|
if (!asset) return false;
|
||||||
|
|
||||||
|
// Close assignment
|
||||||
|
assignment.isCurrent = false;
|
||||||
|
assignment.endDate = endDate;
|
||||||
|
assignment.hoursAtEnd = asset.currentHours;
|
||||||
|
assignment.kilometersAtEnd = asset.currentKilometers;
|
||||||
|
assignment.updatedBy = userId;
|
||||||
|
await this.assignmentRepository.save(assignment);
|
||||||
|
|
||||||
|
// Update asset status
|
||||||
|
await this.update(tenantId, assetId, {
|
||||||
|
currentProjectId: undefined,
|
||||||
|
status: AssetStatus.AVAILABLE,
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CATEGORIES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create category
|
||||||
|
*/
|
||||||
|
async createCategory(tenantId: string, data: Partial<AssetCategory>, userId?: string): Promise<AssetCategory> {
|
||||||
|
const category = this.categoryRepository.create({
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return this.categoryRepository.save(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all categories
|
||||||
|
*/
|
||||||
|
async getCategories(tenantId: string): Promise<AssetCategory[]> {
|
||||||
|
return this.categoryRepository.find({
|
||||||
|
where: { tenantId, isActive: true, deletedAt: null },
|
||||||
|
order: { level: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// STATISTICS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset statistics
|
||||||
|
*/
|
||||||
|
async getStatistics(tenantId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
totalValue: number;
|
||||||
|
maintenanceDue: number;
|
||||||
|
}> {
|
||||||
|
const [total, byStatusRaw, byTypeRaw, valueResult, maintenanceDue] = await Promise.all([
|
||||||
|
this.assetRepository.count({ where: { tenantId, deletedAt: null } }),
|
||||||
|
|
||||||
|
this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.select('asset.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.groupBy('asset.status')
|
||||||
|
.getRawMany(),
|
||||||
|
|
||||||
|
this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.select('asset.asset_type', 'type')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.groupBy('asset.asset_type')
|
||||||
|
.getRawMany(),
|
||||||
|
|
||||||
|
this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.select('SUM(asset.current_book_value)', 'total')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.getRawOne(),
|
||||||
|
|
||||||
|
this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.andWhere('asset.next_maintenance_date <= :date', { date: new Date() })
|
||||||
|
.getCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
byStatusRaw.forEach((row: any) => {
|
||||||
|
byStatus[row.status] = parseInt(row.count, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byType: Record<string, number> = {};
|
||||||
|
byTypeRaw.forEach((row: any) => {
|
||||||
|
byType[row.type] = parseInt(row.count, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
byStatus,
|
||||||
|
byType,
|
||||||
|
totalValue: parseFloat(valueResult?.total) || 0,
|
||||||
|
maintenanceDue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assets needing maintenance
|
||||||
|
*/
|
||||||
|
async getAssetsNeedingMaintenance(tenantId: string): Promise<Asset[]> {
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
return this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED })
|
||||||
|
.andWhere(
|
||||||
|
'(asset.next_maintenance_date <= :today OR asset.current_hours >= asset.next_maintenance_hours OR asset.current_kilometers >= asset.next_maintenance_kilometers)',
|
||||||
|
{ today }
|
||||||
|
)
|
||||||
|
.orderBy('asset.next_maintenance_date', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search assets for autocomplete
|
||||||
|
*/
|
||||||
|
async search(tenantId: string, query: string, limit = 10): Promise<Asset[]> {
|
||||||
|
return this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL')
|
||||||
|
.andWhere(
|
||||||
|
'(asset.asset_code ILIKE :query OR asset.name ILIKE :query OR asset.serial_number ILIKE :query)',
|
||||||
|
{ query: `%${query}%` }
|
||||||
|
)
|
||||||
|
.orderBy('asset.name', 'ASC')
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/modules/assets/services/fuel-log.service.ts
Normal file
232
src/modules/assets/services/fuel-log.service.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* FuelLog Service
|
||||||
|
* ERP Construccion - Modulo Activos (MAE-015)
|
||||||
|
*
|
||||||
|
* Logica de negocio para registro de combustible y calculo de rendimiento.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, Between } from 'typeorm';
|
||||||
|
import { FuelLog } from '../entities/fuel-log.entity';
|
||||||
|
import { Asset } from '../entities/asset.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateFuelLogDto {
|
||||||
|
assetId: string;
|
||||||
|
logDate: Date;
|
||||||
|
logTime?: string;
|
||||||
|
projectId?: string;
|
||||||
|
location?: string;
|
||||||
|
fuelType: string;
|
||||||
|
quantityLiters: number;
|
||||||
|
unitPrice: number;
|
||||||
|
odometerReading?: number;
|
||||||
|
hoursReading?: number;
|
||||||
|
vendorName?: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
operatorName?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuelLogFilters {
|
||||||
|
assetId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
fuelType?: string;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuelStatistics {
|
||||||
|
totalLiters: number;
|
||||||
|
totalCost: number;
|
||||||
|
avgLitersPer100km: number;
|
||||||
|
avgLitersPerHour: number;
|
||||||
|
logsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FuelLogService {
|
||||||
|
private fuelLogRepository: Repository<FuelLog>;
|
||||||
|
private assetRepository: Repository<Asset>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.fuelLogRepository = dataSource.getRepository(FuelLog);
|
||||||
|
this.assetRepository = dataSource.getRepository(Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new fuel log entry
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, dto: CreateFuelLogDto, userId?: string): Promise<FuelLog> {
|
||||||
|
// Calculate total cost
|
||||||
|
const totalCost = dto.quantityLiters * dto.unitPrice;
|
||||||
|
|
||||||
|
// Get last fuel log for efficiency calculation
|
||||||
|
const lastLog = await this.fuelLogRepository.findOne({
|
||||||
|
where: { tenantId, assetId: dto.assetId },
|
||||||
|
order: { logDate: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let kilometersSinceLast: number | undefined;
|
||||||
|
let hoursSinceLast: number | undefined;
|
||||||
|
let litersPer100km: number | undefined;
|
||||||
|
let litersPerHour: number | undefined;
|
||||||
|
|
||||||
|
if (lastLog) {
|
||||||
|
if (dto.odometerReading && lastLog.odometerReading) {
|
||||||
|
kilometersSinceLast = dto.odometerReading - Number(lastLog.odometerReading);
|
||||||
|
if (kilometersSinceLast > 0 && dto.quantityLiters > 0) {
|
||||||
|
litersPer100km = (dto.quantityLiters / kilometersSinceLast) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.hoursReading && lastLog.hoursReading) {
|
||||||
|
hoursSinceLast = dto.hoursReading - Number(lastLog.hoursReading);
|
||||||
|
if (hoursSinceLast > 0 && dto.quantityLiters > 0) {
|
||||||
|
litersPerHour = dto.quantityLiters / hoursSinceLast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuelLog = this.fuelLogRepository.create({
|
||||||
|
tenantId,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
logDate: dto.logDate,
|
||||||
|
logTime: dto.logTime,
|
||||||
|
projectId: dto.projectId,
|
||||||
|
location: dto.location,
|
||||||
|
fuelType: dto.fuelType,
|
||||||
|
quantityLiters: dto.quantityLiters,
|
||||||
|
unitPrice: dto.unitPrice,
|
||||||
|
totalCost,
|
||||||
|
odometerReading: dto.odometerReading,
|
||||||
|
hoursReading: dto.hoursReading,
|
||||||
|
kilometersSinceLast,
|
||||||
|
hoursSinceLast,
|
||||||
|
litersPer100km,
|
||||||
|
litersPerHour,
|
||||||
|
vendorName: dto.vendorName,
|
||||||
|
invoiceNumber: dto.invoiceNumber,
|
||||||
|
operatorId: dto.operatorId,
|
||||||
|
operatorName: dto.operatorName,
|
||||||
|
notes: dto.notes,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedLog = await this.fuelLogRepository.save(fuelLog);
|
||||||
|
|
||||||
|
// Update asset current readings if provided
|
||||||
|
if (dto.odometerReading || dto.hoursReading) {
|
||||||
|
await this.assetRepository.update(
|
||||||
|
{ id: dto.assetId, tenantId },
|
||||||
|
{
|
||||||
|
currentKilometers: dto.odometerReading,
|
||||||
|
currentHours: dto.hoursReading,
|
||||||
|
lastUsageUpdate: new Date(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find fuel log by ID
|
||||||
|
*/
|
||||||
|
async findById(tenantId: string, id: string): Promise<FuelLog | null> {
|
||||||
|
return this.fuelLogRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
relations: ['asset'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List fuel logs with filters
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: FuelLogFilters = {},
|
||||||
|
pagination = { page: 1, limit: 20 }
|
||||||
|
) {
|
||||||
|
const queryBuilder = this.fuelLogRepository.createQueryBuilder('fl')
|
||||||
|
.leftJoinAndSelect('fl.asset', 'asset')
|
||||||
|
.where('fl.tenant_id = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (filters.assetId) {
|
||||||
|
queryBuilder.andWhere('fl.asset_id = :assetId', { assetId: filters.assetId });
|
||||||
|
}
|
||||||
|
if (filters.projectId) {
|
||||||
|
queryBuilder.andWhere('fl.project_id = :projectId', { projectId: filters.projectId });
|
||||||
|
}
|
||||||
|
if (filters.fuelType) {
|
||||||
|
queryBuilder.andWhere('fl.fuel_type = :fuelType', { fuelType: filters.fuelType });
|
||||||
|
}
|
||||||
|
if (filters.fromDate) {
|
||||||
|
queryBuilder.andWhere('fl.log_date >= :fromDate', { fromDate: filters.fromDate });
|
||||||
|
}
|
||||||
|
if (filters.toDate) {
|
||||||
|
queryBuilder.andWhere('fl.log_date <= :toDate', { toDate: filters.toDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (pagination.page - 1) * pagination.limit;
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.orderBy('fl.log_date', 'DESC')
|
||||||
|
.addOrderBy('fl.created_at', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(pagination.limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fuel statistics for an asset
|
||||||
|
*/
|
||||||
|
async getAssetStatistics(
|
||||||
|
tenantId: string,
|
||||||
|
assetId: string,
|
||||||
|
fromDate?: Date,
|
||||||
|
toDate?: Date
|
||||||
|
): Promise<FuelStatistics> {
|
||||||
|
const queryBuilder = this.fuelLogRepository.createQueryBuilder('fl')
|
||||||
|
.where('fl.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('fl.asset_id = :assetId', { assetId });
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
queryBuilder.andWhere('fl.log_date >= :fromDate', { fromDate });
|
||||||
|
}
|
||||||
|
if (toDate) {
|
||||||
|
queryBuilder.andWhere('fl.log_date <= :toDate', { toDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryBuilder
|
||||||
|
.select('SUM(fl.quantity_liters)', 'totalLiters')
|
||||||
|
.addSelect('SUM(fl.total_cost)', 'totalCost')
|
||||||
|
.addSelect('AVG(fl.liters_per_100km)', 'avgLitersPer100km')
|
||||||
|
.addSelect('AVG(fl.liters_per_hour)', 'avgLitersPerHour')
|
||||||
|
.addSelect('COUNT(*)', 'logsCount')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLiters: parseFloat(result?.totalLiters) || 0,
|
||||||
|
totalCost: parseFloat(result?.totalCost) || 0,
|
||||||
|
avgLitersPer100km: parseFloat(result?.avgLitersPer100km) || 0,
|
||||||
|
avgLitersPerHour: parseFloat(result?.avgLitersPerHour) || 0,
|
||||||
|
logsCount: parseInt(result?.logsCount, 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete fuel log entry
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string): Promise<boolean> {
|
||||||
|
const result = await this.fuelLogRepository.delete({ id, tenantId });
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/modules/assets/services/index.ts
Normal file
8
src/modules/assets/services/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Assets Services Index
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './asset.service';
|
||||||
|
export * from './work-order.service';
|
||||||
|
export * from './fuel-log.service';
|
||||||
750
src/modules/assets/services/work-order.service.ts
Normal file
750
src/modules/assets/services/work-order.service.ts
Normal file
@ -0,0 +1,750 @@
|
|||||||
|
/**
|
||||||
|
* WorkOrder Service
|
||||||
|
* ERP Construccion - Modulo Activos (MAE-015)
|
||||||
|
*
|
||||||
|
* Logica de negocio para ordenes de trabajo de mantenimiento.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, LessThanOrEqual, In } from 'typeorm';
|
||||||
|
import { WorkOrder, WorkOrderStatus, WorkOrderPriority, MaintenanceType } from '../entities/work-order.entity';
|
||||||
|
import { WorkOrderPart } from '../entities/work-order-part.entity';
|
||||||
|
import { MaintenanceHistory } from '../entities/maintenance-history.entity';
|
||||||
|
import { MaintenancePlan } from '../entities/maintenance-plan.entity';
|
||||||
|
import { Asset } from '../entities/asset.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateWorkOrderDto {
|
||||||
|
assetId: string;
|
||||||
|
maintenanceType: MaintenanceType;
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
problemReported?: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectName?: string;
|
||||||
|
scheduledStartDate?: Date;
|
||||||
|
scheduledEndDate?: Date;
|
||||||
|
assignedToId?: string;
|
||||||
|
assignedToName?: string;
|
||||||
|
estimatedHours?: number;
|
||||||
|
activitiesChecklist?: Record<string, any>[];
|
||||||
|
isScheduled?: boolean;
|
||||||
|
scheduleId?: string;
|
||||||
|
planId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkOrderDto {
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
diagnosis?: string;
|
||||||
|
scheduledStartDate?: Date;
|
||||||
|
scheduledEndDate?: Date;
|
||||||
|
actualStartDate?: Date;
|
||||||
|
actualEndDate?: Date;
|
||||||
|
assignedToId?: string;
|
||||||
|
assignedToName?: string;
|
||||||
|
workPerformed?: string;
|
||||||
|
findings?: string;
|
||||||
|
recommendations?: string;
|
||||||
|
activitiesChecklist?: Record<string, any>[];
|
||||||
|
actualHours?: number;
|
||||||
|
laborCost?: number;
|
||||||
|
externalServiceCost?: number;
|
||||||
|
otherCosts?: number;
|
||||||
|
requiresFollowup?: boolean;
|
||||||
|
followupNotes?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddPartDto {
|
||||||
|
partId?: string;
|
||||||
|
partCode?: string;
|
||||||
|
partName: string;
|
||||||
|
partDescription?: string;
|
||||||
|
quantityRequired: number;
|
||||||
|
quantityUsed?: number;
|
||||||
|
unitCost?: number;
|
||||||
|
fromInventory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderFilters {
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
maintenanceType?: MaintenanceType;
|
||||||
|
assetId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteWorkOrderDto {
|
||||||
|
workPerformed: string;
|
||||||
|
findings?: string;
|
||||||
|
recommendations?: string;
|
||||||
|
actualHours: number;
|
||||||
|
laborCost: number;
|
||||||
|
completedById?: string;
|
||||||
|
completedByName?: string;
|
||||||
|
completionNotes?: string;
|
||||||
|
photosAfter?: string[];
|
||||||
|
requiresFollowup?: boolean;
|
||||||
|
followupNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkOrderService {
|
||||||
|
private workOrderRepository: Repository<WorkOrder>;
|
||||||
|
private partRepository: Repository<WorkOrderPart>;
|
||||||
|
private historyRepository: Repository<MaintenanceHistory>;
|
||||||
|
private planRepository: Repository<MaintenancePlan>;
|
||||||
|
private assetRepository: Repository<Asset>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.workOrderRepository = dataSource.getRepository(WorkOrder);
|
||||||
|
this.partRepository = dataSource.getRepository(WorkOrderPart);
|
||||||
|
this.historyRepository = dataSource.getRepository(MaintenanceHistory);
|
||||||
|
this.planRepository = dataSource.getRepository(MaintenancePlan);
|
||||||
|
this.assetRepository = dataSource.getRepository(Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate next work order number
|
||||||
|
*/
|
||||||
|
private async generateWorkOrderNumber(tenantId: string): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `OT-${year}-`;
|
||||||
|
|
||||||
|
const lastOrder = await this.workOrderRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let sequence = 1;
|
||||||
|
if (lastOrder?.workOrderNumber?.startsWith(prefix)) {
|
||||||
|
const lastSeq = parseInt(lastOrder.workOrderNumber.replace(prefix, ''), 10);
|
||||||
|
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new work order
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, dto: CreateWorkOrderDto, userId?: string): Promise<WorkOrder> {
|
||||||
|
// Get asset info
|
||||||
|
const asset = await this.assetRepository.findOne({
|
||||||
|
where: { id: dto.assetId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('Asset not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workOrderNumber = await this.generateWorkOrderNumber(tenantId);
|
||||||
|
|
||||||
|
const workOrder = this.workOrderRepository.create({
|
||||||
|
tenantId,
|
||||||
|
workOrderNumber,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
assetCode: asset.assetCode,
|
||||||
|
assetName: asset.name,
|
||||||
|
maintenanceType: dto.maintenanceType,
|
||||||
|
priority: dto.priority || WorkOrderPriority.MEDIUM,
|
||||||
|
status: WorkOrderStatus.DRAFT,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
problemReported: dto.problemReported,
|
||||||
|
projectId: dto.projectId || asset.currentProjectId,
|
||||||
|
projectName: dto.projectName,
|
||||||
|
requestedDate: new Date(),
|
||||||
|
scheduledStartDate: dto.scheduledStartDate,
|
||||||
|
scheduledEndDate: dto.scheduledEndDate,
|
||||||
|
hoursAtWorkOrder: asset.currentHours,
|
||||||
|
kilometersAtWorkOrder: asset.currentKilometers,
|
||||||
|
assignedToId: dto.assignedToId,
|
||||||
|
assignedToName: dto.assignedToName,
|
||||||
|
requestedById: userId,
|
||||||
|
estimatedHours: dto.estimatedHours,
|
||||||
|
activitiesChecklist: dto.activitiesChecklist,
|
||||||
|
isScheduled: dto.isScheduled || false,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
planId: dto.planId,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.workOrderRepository.save(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find work order by ID
|
||||||
|
*/
|
||||||
|
async findById(tenantId: string, id: string): Promise<WorkOrder | null> {
|
||||||
|
return this.workOrderRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
relations: ['asset', 'partsUsed'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find work order by number
|
||||||
|
*/
|
||||||
|
async findByNumber(tenantId: string, workOrderNumber: string): Promise<WorkOrder | null> {
|
||||||
|
return this.workOrderRepository.findOne({
|
||||||
|
where: { tenantId, workOrderNumber },
|
||||||
|
relations: ['asset', 'partsUsed'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List work orders with filters
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: WorkOrderFilters = {},
|
||||||
|
pagination = { page: 1, limit: 20 }
|
||||||
|
) {
|
||||||
|
const queryBuilder = this.workOrderRepository.createQueryBuilder('wo')
|
||||||
|
.leftJoinAndSelect('wo.asset', 'asset')
|
||||||
|
.where('wo.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('wo.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
queryBuilder.andWhere('wo.status = :status', { status: filters.status });
|
||||||
|
}
|
||||||
|
if (filters.priority) {
|
||||||
|
queryBuilder.andWhere('wo.priority = :priority', { priority: filters.priority });
|
||||||
|
}
|
||||||
|
if (filters.maintenanceType) {
|
||||||
|
queryBuilder.andWhere('wo.maintenance_type = :maintenanceType', { maintenanceType: filters.maintenanceType });
|
||||||
|
}
|
||||||
|
if (filters.assetId) {
|
||||||
|
queryBuilder.andWhere('wo.asset_id = :assetId', { assetId: filters.assetId });
|
||||||
|
}
|
||||||
|
if (filters.projectId) {
|
||||||
|
queryBuilder.andWhere('wo.project_id = :projectId', { projectId: filters.projectId });
|
||||||
|
}
|
||||||
|
if (filters.assignedToId) {
|
||||||
|
queryBuilder.andWhere('wo.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
|
||||||
|
}
|
||||||
|
if (filters.fromDate) {
|
||||||
|
queryBuilder.andWhere('wo.requested_date >= :fromDate', { fromDate: filters.fromDate });
|
||||||
|
}
|
||||||
|
if (filters.toDate) {
|
||||||
|
queryBuilder.andWhere('wo.requested_date <= :toDate', { toDate: filters.toDate });
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(wo.work_order_number ILIKE :search OR wo.title ILIKE :search OR wo.asset_name ILIKE :search)',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (pagination.page - 1) * pagination.limit;
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.orderBy('wo.requested_date', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(pagination.limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update work order
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, id: string, dto: UpdateWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
const workOrder = await this.findById(tenantId, id);
|
||||||
|
if (!workOrder) return null;
|
||||||
|
|
||||||
|
// Handle status transitions
|
||||||
|
if (dto.status && dto.status !== workOrder.status) {
|
||||||
|
this.validateStatusTransition(workOrder.status, dto.status);
|
||||||
|
this.applyStatusSideEffects(workOrder, dto.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(workOrder, dto, { updatedBy: userId });
|
||||||
|
|
||||||
|
// Recalculate total cost
|
||||||
|
workOrder.totalCost =
|
||||||
|
(workOrder.laborCost || 0) +
|
||||||
|
(workOrder.partsCost || 0) +
|
||||||
|
(workOrder.externalServiceCost || 0) +
|
||||||
|
(workOrder.otherCosts || 0);
|
||||||
|
|
||||||
|
return this.workOrderRepository.save(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate status transition
|
||||||
|
*/
|
||||||
|
private validateStatusTransition(from: WorkOrderStatus, to: WorkOrderStatus): void {
|
||||||
|
const validTransitions: Record<WorkOrderStatus, WorkOrderStatus[]> = {
|
||||||
|
[WorkOrderStatus.DRAFT]: [WorkOrderStatus.SCHEDULED, WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
|
||||||
|
[WorkOrderStatus.SCHEDULED]: [WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
|
||||||
|
[WorkOrderStatus.IN_PROGRESS]: [WorkOrderStatus.ON_HOLD, WorkOrderStatus.COMPLETED, WorkOrderStatus.CANCELLED],
|
||||||
|
[WorkOrderStatus.ON_HOLD]: [WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED],
|
||||||
|
[WorkOrderStatus.COMPLETED]: [],
|
||||||
|
[WorkOrderStatus.CANCELLED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validTransitions[from].includes(to)) {
|
||||||
|
throw new Error(`Invalid status transition from ${from} to ${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply side effects when status changes
|
||||||
|
*/
|
||||||
|
private applyStatusSideEffects(workOrder: WorkOrder, newStatus: WorkOrderStatus): void {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (newStatus) {
|
||||||
|
case WorkOrderStatus.IN_PROGRESS:
|
||||||
|
if (!workOrder.actualStartDate) {
|
||||||
|
workOrder.actualStartDate = now;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WorkOrderStatus.COMPLETED:
|
||||||
|
workOrder.actualEndDate = now;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start work order (change to in_progress)
|
||||||
|
*/
|
||||||
|
async start(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
return this.update(tenantId, id, { status: WorkOrderStatus.IN_PROGRESS }, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete work order
|
||||||
|
*/
|
||||||
|
async complete(tenantId: string, id: string, dto: CompleteWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
const workOrder = await this.findById(tenantId, id);
|
||||||
|
if (!workOrder) return null;
|
||||||
|
|
||||||
|
// Update work order
|
||||||
|
workOrder.status = WorkOrderStatus.COMPLETED;
|
||||||
|
workOrder.actualEndDate = new Date();
|
||||||
|
workOrder.workPerformed = dto.workPerformed;
|
||||||
|
workOrder.findings = dto.findings;
|
||||||
|
workOrder.recommendations = dto.recommendations;
|
||||||
|
workOrder.actualHours = dto.actualHours;
|
||||||
|
workOrder.laborCost = dto.laborCost;
|
||||||
|
workOrder.completedById = dto.completedById || userId;
|
||||||
|
workOrder.completedByName = dto.completedByName;
|
||||||
|
workOrder.completionNotes = dto.completionNotes;
|
||||||
|
workOrder.photosAfter = dto.photosAfter;
|
||||||
|
workOrder.requiresFollowup = dto.requiresFollowup || false;
|
||||||
|
workOrder.followupNotes = dto.followupNotes;
|
||||||
|
workOrder.updatedBy = userId;
|
||||||
|
|
||||||
|
// Recalculate total cost
|
||||||
|
workOrder.totalCost =
|
||||||
|
(workOrder.laborCost || 0) +
|
||||||
|
(workOrder.partsCost || 0) +
|
||||||
|
(workOrder.externalServiceCost || 0) +
|
||||||
|
(workOrder.otherCosts || 0);
|
||||||
|
|
||||||
|
const savedWorkOrder = await this.workOrderRepository.save(workOrder);
|
||||||
|
|
||||||
|
// Create maintenance history record
|
||||||
|
await this.historyRepository.save({
|
||||||
|
tenantId,
|
||||||
|
assetId: workOrder.assetId,
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
maintenanceDate: new Date(),
|
||||||
|
maintenanceType: workOrder.maintenanceType,
|
||||||
|
description: workOrder.title,
|
||||||
|
workPerformed: dto.workPerformed,
|
||||||
|
hoursAtMaintenance: workOrder.hoursAtWorkOrder,
|
||||||
|
kilometersAtMaintenance: workOrder.kilometersAtWorkOrder,
|
||||||
|
laborCost: workOrder.laborCost,
|
||||||
|
partsCost: workOrder.partsCost,
|
||||||
|
totalCost: workOrder.totalCost,
|
||||||
|
performedById: dto.completedById || userId,
|
||||||
|
performedByName: dto.completedByName,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return savedWorkOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add part to work order
|
||||||
|
*/
|
||||||
|
async addPart(tenantId: string, workOrderId: string, dto: AddPartDto, userId?: string): Promise<WorkOrderPart> {
|
||||||
|
const workOrder = await this.findById(tenantId, workOrderId);
|
||||||
|
if (!workOrder) {
|
||||||
|
throw new Error('Work order not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost = dto.unitCost && dto.quantityUsed
|
||||||
|
? dto.unitCost * dto.quantityUsed
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const part = this.partRepository.create({
|
||||||
|
tenantId,
|
||||||
|
workOrderId,
|
||||||
|
partId: dto.partId,
|
||||||
|
partCode: dto.partCode,
|
||||||
|
partName: dto.partName,
|
||||||
|
partDescription: dto.partDescription,
|
||||||
|
quantityRequired: dto.quantityRequired,
|
||||||
|
quantityUsed: dto.quantityUsed,
|
||||||
|
unitCost: dto.unitCost,
|
||||||
|
totalCost,
|
||||||
|
fromInventory: dto.fromInventory || false,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedPart = await this.partRepository.save(part);
|
||||||
|
|
||||||
|
// Update parts cost in work order
|
||||||
|
await this.recalculatePartsCost(workOrderId);
|
||||||
|
|
||||||
|
return savedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate parts cost for work order
|
||||||
|
*/
|
||||||
|
private async recalculatePartsCost(workOrderId: string): Promise<void> {
|
||||||
|
const parts = await this.partRepository.find({ where: { workOrderId } });
|
||||||
|
|
||||||
|
const partsCost = parts.reduce((sum, part) => sum + (Number(part.totalCost) || 0), 0);
|
||||||
|
const partsCount = parts.length;
|
||||||
|
|
||||||
|
await this.workOrderRepository.update(
|
||||||
|
{ id: workOrderId },
|
||||||
|
{
|
||||||
|
partsCost,
|
||||||
|
partsUsedCount: partsCount,
|
||||||
|
totalCost: () => `labor_cost + ${partsCost} + external_service_cost + other_costs`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get work order statistics
|
||||||
|
*/
|
||||||
|
async getStatistics(tenantId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
pendingCount: number;
|
||||||
|
inProgressCount: number;
|
||||||
|
completedThisMonth: number;
|
||||||
|
totalCostThisMonth: number;
|
||||||
|
}> {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const [total, byStatusRaw, byTypeRaw, pendingCount, inProgressCount, completedThisMonth, costResult] =
|
||||||
|
await Promise.all([
|
||||||
|
this.workOrderRepository.count({ where: { tenantId, deletedAt: null } }),
|
||||||
|
|
||||||
|
this.workOrderRepository.createQueryBuilder('wo')
|
||||||
|
.select('wo.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('wo.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('wo.deleted_at IS NULL')
|
||||||
|
.groupBy('wo.status')
|
||||||
|
.getRawMany(),
|
||||||
|
|
||||||
|
this.workOrderRepository.createQueryBuilder('wo')
|
||||||
|
.select('wo.maintenance_type', 'type')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('wo.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('wo.deleted_at IS NULL')
|
||||||
|
.groupBy('wo.maintenance_type')
|
||||||
|
.getRawMany(),
|
||||||
|
|
||||||
|
this.workOrderRepository.count({
|
||||||
|
where: { tenantId, status: WorkOrderStatus.DRAFT, deletedAt: null },
|
||||||
|
}),
|
||||||
|
|
||||||
|
this.workOrderRepository.count({
|
||||||
|
where: { tenantId, status: WorkOrderStatus.IN_PROGRESS, deletedAt: null },
|
||||||
|
}),
|
||||||
|
|
||||||
|
this.workOrderRepository.createQueryBuilder('wo')
|
||||||
|
.where('wo.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('wo.status = :status', { status: WorkOrderStatus.COMPLETED })
|
||||||
|
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
|
||||||
|
.getCount(),
|
||||||
|
|
||||||
|
this.workOrderRepository.createQueryBuilder('wo')
|
||||||
|
.select('SUM(wo.total_cost)', 'total')
|
||||||
|
.where('wo.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('wo.status = :status', { status: WorkOrderStatus.COMPLETED })
|
||||||
|
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
|
||||||
|
.getRawOne(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
byStatusRaw.forEach((row: any) => {
|
||||||
|
byStatus[row.status] = parseInt(row.count, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byType: Record<string, number> = {};
|
||||||
|
byTypeRaw.forEach((row: any) => {
|
||||||
|
byType[row.type] = parseInt(row.count, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
byStatus,
|
||||||
|
byType,
|
||||||
|
pendingCount,
|
||||||
|
inProgressCount,
|
||||||
|
completedThisMonth,
|
||||||
|
totalCostThisMonth: parseFloat(costResult?.total) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get work orders grouped by status (for Kanban)
|
||||||
|
*/
|
||||||
|
async getByStatus(tenantId: string): Promise<Record<WorkOrderStatus, WorkOrder[]>> {
|
||||||
|
const workOrders = await this.workOrderRepository.find({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
relations: ['asset'],
|
||||||
|
order: { requestedDate: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped: Record<WorkOrderStatus, WorkOrder[]> = {
|
||||||
|
[WorkOrderStatus.DRAFT]: [],
|
||||||
|
[WorkOrderStatus.SCHEDULED]: [],
|
||||||
|
[WorkOrderStatus.IN_PROGRESS]: [],
|
||||||
|
[WorkOrderStatus.ON_HOLD]: [],
|
||||||
|
[WorkOrderStatus.COMPLETED]: [],
|
||||||
|
[WorkOrderStatus.CANCELLED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const wo of workOrders) {
|
||||||
|
grouped[wo.status].push(wo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put work order on hold
|
||||||
|
*/
|
||||||
|
async hold(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
const workOrder = await this.findById(tenantId, id);
|
||||||
|
if (!workOrder) return null;
|
||||||
|
|
||||||
|
this.validateStatusTransition(workOrder.status, WorkOrderStatus.ON_HOLD);
|
||||||
|
|
||||||
|
workOrder.status = WorkOrderStatus.ON_HOLD;
|
||||||
|
workOrder.notes = reason ? `${workOrder.notes || ''}\n[ON HOLD] ${reason}` : workOrder.notes;
|
||||||
|
workOrder.updatedBy = userId;
|
||||||
|
|
||||||
|
return this.workOrderRepository.save(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume work order from hold
|
||||||
|
*/
|
||||||
|
async resume(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
const workOrder = await this.findById(tenantId, id);
|
||||||
|
if (!workOrder) return null;
|
||||||
|
|
||||||
|
this.validateStatusTransition(workOrder.status, WorkOrderStatus.IN_PROGRESS);
|
||||||
|
|
||||||
|
workOrder.status = WorkOrderStatus.IN_PROGRESS;
|
||||||
|
workOrder.updatedBy = userId;
|
||||||
|
|
||||||
|
return this.workOrderRepository.save(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel work order
|
||||||
|
*/
|
||||||
|
async cancel(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
|
||||||
|
const workOrder = await this.findById(tenantId, id);
|
||||||
|
if (!workOrder) return null;
|
||||||
|
|
||||||
|
this.validateStatusTransition(workOrder.status, WorkOrderStatus.CANCELLED);
|
||||||
|
|
||||||
|
workOrder.status = WorkOrderStatus.CANCELLED;
|
||||||
|
workOrder.notes = reason ? `${workOrder.notes || ''}\n[CANCELLED] ${reason}` : workOrder.notes;
|
||||||
|
workOrder.updatedBy = userId;
|
||||||
|
|
||||||
|
return this.workOrderRepository.save(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete work order
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string, userId?: string): Promise<boolean> {
|
||||||
|
const result = await this.workOrderRepository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{ deletedAt: new Date(), updatedBy: userId }
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parts for a work order
|
||||||
|
*/
|
||||||
|
async getParts(tenantId: string, workOrderId: string): Promise<WorkOrderPart[]> {
|
||||||
|
return this.partRepository.find({
|
||||||
|
where: { tenantId, workOrderId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a part
|
||||||
|
*/
|
||||||
|
async updatePart(tenantId: string, partId: string, dto: Partial<AddPartDto>, userId?: string): Promise<WorkOrderPart | null> {
|
||||||
|
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
|
||||||
|
if (!part) return null;
|
||||||
|
|
||||||
|
if (dto.quantityUsed !== undefined) {
|
||||||
|
part.quantityUsed = dto.quantityUsed;
|
||||||
|
}
|
||||||
|
if (dto.unitCost !== undefined) {
|
||||||
|
part.unitCost = dto.unitCost;
|
||||||
|
}
|
||||||
|
if (part.unitCost && part.quantityUsed) {
|
||||||
|
part.totalCost = part.unitCost * part.quantityUsed;
|
||||||
|
}
|
||||||
|
part.updatedBy = userId;
|
||||||
|
|
||||||
|
const savedPart = await this.partRepository.save(part);
|
||||||
|
await this.recalculatePartsCost(part.workOrderId);
|
||||||
|
|
||||||
|
return savedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a part from work order
|
||||||
|
*/
|
||||||
|
async removePart(tenantId: string, partId: string, userId?: string): Promise<boolean> {
|
||||||
|
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
|
||||||
|
if (!part) return false;
|
||||||
|
|
||||||
|
const workOrderId = part.workOrderId;
|
||||||
|
await this.partRepository.remove(part);
|
||||||
|
await this.recalculatePartsCost(workOrderId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overdue work orders
|
||||||
|
*/
|
||||||
|
async getOverdue(tenantId: string): Promise<WorkOrder[]> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return this.workOrderRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
status: In([WorkOrderStatus.DRAFT, WorkOrderStatus.SCHEDULED, WorkOrderStatus.IN_PROGRESS]),
|
||||||
|
scheduledEndDate: LessThanOrEqual(now),
|
||||||
|
},
|
||||||
|
relations: ['asset'],
|
||||||
|
order: { scheduledEndDate: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get maintenance plans
|
||||||
|
*/
|
||||||
|
async getMaintenancePlans(tenantId: string, assetId?: string): Promise<MaintenancePlan[]> {
|
||||||
|
const where: any = { tenantId, isActive: true, deletedAt: undefined };
|
||||||
|
if (assetId) {
|
||||||
|
where.assetId = assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.planRepository.find({
|
||||||
|
where,
|
||||||
|
relations: ['asset', 'category'],
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a maintenance plan
|
||||||
|
*/
|
||||||
|
async createMaintenancePlan(tenantId: string, data: Partial<MaintenancePlan>, userId?: string): Promise<MaintenancePlan> {
|
||||||
|
const plan = this.planRepository.create({
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.planRepository.save(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate work orders from a maintenance plan
|
||||||
|
*/
|
||||||
|
async generateFromPlan(tenantId: string, planId: string, userId?: string): Promise<WorkOrder[]> {
|
||||||
|
const plan = await this.planRepository.findOne({
|
||||||
|
where: { id: planId, tenantId },
|
||||||
|
relations: ['asset'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Maintenance plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets: Asset[] = [];
|
||||||
|
|
||||||
|
if (plan.assetId && plan.asset) {
|
||||||
|
assets.push(plan.asset);
|
||||||
|
} else if (plan.categoryId || plan.assetType) {
|
||||||
|
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
|
||||||
|
.where('asset.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('asset.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (plan.categoryId) {
|
||||||
|
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: plan.categoryId });
|
||||||
|
}
|
||||||
|
if (plan.assetType) {
|
||||||
|
queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: plan.assetType });
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundAssets = await queryBuilder.getMany();
|
||||||
|
assets.push(...foundAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdOrders: WorkOrder[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const workOrder = await this.create(tenantId, {
|
||||||
|
assetId: asset.id,
|
||||||
|
maintenanceType: plan.maintenanceType as MaintenanceType,
|
||||||
|
title: `[${plan.planCode}] ${plan.name}`,
|
||||||
|
description: plan.description,
|
||||||
|
estimatedHours: plan.estimatedDurationHours ? Number(plan.estimatedDurationHours) : undefined,
|
||||||
|
activitiesChecklist: plan.activities,
|
||||||
|
isScheduled: true,
|
||||||
|
planId: plan.id,
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
createdOrders.push(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdOrders;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user