diff --git a/src/main.ts b/src/main.ts index 089da18..6ff73c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,12 @@ import { createGpsPositionController } from './modules/gps/controllers/gps-posit import { createGeofenceController } from './modules/gps/controllers/geofence.controller'; import { createRouteSegmentController } from './modules/gps/controllers/route-segment.controller'; +// Assets Module Controllers +import { createAssetController } from './modules/assets/controllers/asset.controller'; +import { createAssetAssignmentController } from './modules/assets/controllers/asset-assignment.controller'; +import { createAssetAuditController } from './modules/assets/controllers/asset-audit.controller'; +import { createAssetMaintenanceController } from './modules/assets/controllers/asset-maintenance.controller'; + // Payment Terminals Module import { PaymentTerminalsModule } from './modules/payment-terminals'; @@ -70,6 +76,14 @@ import { Geofence } from './modules/gps/entities/geofence.entity'; import { GeofenceEvent } from './modules/gps/entities/geofence-event.entity'; import { RouteSegment } from './modules/gps/entities/route-segment.entity'; +// Entities - Assets +import { Asset } from './modules/assets/entities/asset.entity'; +import { AssetCategory } from './modules/assets/entities/asset-category.entity'; +import { AssetAssignment } from './modules/assets/entities/asset-assignment.entity'; +import { AssetAudit } from './modules/assets/entities/asset-audit.entity'; +import { AssetAuditItem } from './modules/assets/entities/asset-audit-item.entity'; +import { AssetMaintenance } from './modules/assets/entities/asset-maintenance.entity'; + // Load environment variables config(); @@ -120,6 +134,13 @@ const AppDataSource = new DataSource({ Geofence, GeofenceEvent, RouteSegment, + // Assets + Asset, + AssetCategory, + AssetAssignment, + AssetAudit, + AssetAuditItem, + AssetMaintenance, ], synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', @@ -173,6 +194,13 @@ async function bootstrap() { app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource)); console.log('📡 GPS module initialized'); + // Assets Module Routes + app.use('/api/v1/assets', createAssetController(AppDataSource)); + app.use('/api/v1/assets/assignments', createAssetAssignmentController(AppDataSource)); + app.use('/api/v1/assets/audits', createAssetAuditController(AppDataSource)); + app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource)); + console.log('📦 Assets module initialized'); + // Payment Terminals Module const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); app.use('/api/v1', paymentTerminals.router); @@ -204,6 +232,12 @@ async function bootstrap() { geofences: '/api/v1/gps/geofences', routes: '/api/v1/gps/routes', }, + assets: { + base: '/api/v1/assets', + assignments: '/api/v1/assets/assignments', + audits: '/api/v1/assets/audits', + maintenance: '/api/v1/assets/maintenance', + }, }, documentation: '/api/v1/docs', }); diff --git a/src/modules/assets/controllers/asset-assignment.controller.ts b/src/modules/assets/controllers/asset-assignment.controller.ts new file mode 100644 index 0000000..8093ca0 --- /dev/null +++ b/src/modules/assets/controllers/asset-assignment.controller.ts @@ -0,0 +1,188 @@ +/** + * AssetAssignment Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for asset assignments. + * Module: MMD-013 Asset Management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AssetAssignmentService, AssignmentFilters } from '../services/asset-assignment.service'; +import { AssignmentStatus } from '../entities/asset-assignment.entity'; +import { AssigneeType } from '../entities/asset.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createAssetAssignmentController(dataSource: DataSource): Router { + const router = Router(); + const service = new AssetAssignmentService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Assign an asset + * POST /api/assets/assignments + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const assignment = await service.assign(req.tenantId!, { + ...req.body, + assignedBy: req.userId || req.body.assignedBy, + }); + res.status(201).json(assignment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List assignments with filters + * GET /api/assets/assignments + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: AssignmentFilters = { + assetId: req.query.assetId as string, + assigneeId: req.query.assigneeId as string, + assigneeType: req.query.assigneeType as AssigneeType, + status: req.query.status as AssignmentStatus, + incidentId: req.query.incidentId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get overdue assignments + * GET /api/assets/assignments/overdue + */ + router.get('/overdue', async (req: TenantRequest, res: Response) => { + try { + const assignments = await service.findOverdue(req.tenantId!); + res.json(assignments); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Validate required assets for assignee + * POST /api/assets/assignments/validate + */ + router.post('/validate', async (req: TenantRequest, res: Response) => { + try { + const { requiredCategoryIds, assigneeId, assigneeType } = req.body; + const result = await service.validateRequiredAssets( + req.tenantId!, + requiredCategoryIds, + assigneeId, + assigneeType + ); + res.json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get assignment history for an asset + * GET /api/assets/assignments/asset/:assetId/history + */ + router.get('/asset/:assetId/history', async (req: TenantRequest, res: Response) => { + try { + const history = await service.getAssetHistory(req.tenantId!, req.params.assetId); + res.json(history); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get assignment history for an assignee + * GET /api/assets/assignments/assignee/:assigneeId/history + */ + router.get('/assignee/:assigneeId/history', async (req: TenantRequest, res: Response) => { + try { + const assigneeType = req.query.type as AssigneeType || AssigneeType.EMPLOYEE; + const history = await service.getAssigneeHistory(req.tenantId!, req.params.assigneeId, assigneeType); + res.json(history); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get assignment by ID + * GET /api/assets/assignments/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const assignment = await service.findById(req.tenantId!, req.params.id); + if (!assignment) { + return res.status(404).json({ error: 'Assignment not found' }); + } + res.json(assignment); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Return an assigned asset + * POST /api/assets/assignments/:id/return + */ + router.post('/:id/return', async (req: TenantRequest, res: Response) => { + try { + const assignment = await service.return(req.tenantId!, req.params.id, { + ...req.body, + returnedTo: req.userId || req.body.returnedTo, + }); + res.json(assignment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Transfer an assignment + * POST /api/assets/assignments/:id/transfer + */ + router.post('/:id/transfer', async (req: TenantRequest, res: Response) => { + try { + const assignment = await service.transfer(req.tenantId!, req.params.id, { + ...req.body, + transferredBy: req.userId || req.body.transferredBy, + }); + res.json(assignment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/assets/controllers/asset-audit.controller.ts b/src/modules/assets/controllers/asset-audit.controller.ts new file mode 100644 index 0000000..c7bd299 --- /dev/null +++ b/src/modules/assets/controllers/asset-audit.controller.ts @@ -0,0 +1,177 @@ +/** + * AssetAudit Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for asset audits. + * Module: MMD-013 Asset Management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AssetAuditService, AuditFilters } from '../services/asset-audit.service'; +import { AuditStatus } from '../entities/asset-audit.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createAssetAuditController(dataSource: DataSource): Router { + const router = Router(); + const service = new AssetAuditService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Start a new audit + * POST /api/assets/audits + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const audit = await service.startAudit(req.tenantId!, { + ...req.body, + auditorId: req.userId || req.body.auditorId, + }); + res.status(201).json(audit); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List audits with filters + * GET /api/assets/audits + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: AuditFilters = { + auditorId: req.query.auditorId as string, + locationId: req.query.locationId as string, + unitId: req.query.unitId as string, + technicianId: req.query.technicianId as string, + status: req.query.status as AuditStatus, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get audit by ID + * GET /api/assets/audits/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const audit = await service.findById(req.tenantId!, req.params.id); + if (!audit) { + return res.status(404).json({ error: 'Audit not found' }); + } + res.json(audit); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get audit items + * GET /api/assets/audits/:id/items + */ + router.get('/:id/items', async (req: TenantRequest, res: Response) => { + try { + const items = await service.getAuditItems(req.params.id); + res.json(items); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get audit summary + * GET /api/assets/audits/:id/summary + */ + router.get('/:id/summary', async (req: TenantRequest, res: Response) => { + try { + const summary = await service.getAuditSummary(req.params.id); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get audit discrepancies + * GET /api/assets/audits/:id/discrepancies + */ + router.get('/:id/discrepancies', async (req: TenantRequest, res: Response) => { + try { + const discrepancies = await service.getDiscrepancies(req.tenantId!, req.params.id); + res.json(discrepancies); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Scan an asset in an audit + * POST /api/assets/audits/:id/scan + */ + router.post('/:id/scan', async (req: TenantRequest, res: Response) => { + try { + const item = await service.scanAsset(req.tenantId!, req.params.id, { + ...req.body, + scannedBy: req.userId || req.body.scannedBy, + }); + res.status(201).json(item); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Complete an audit + * POST /api/assets/audits/:id/complete + */ + router.post('/:id/complete', async (req: TenantRequest, res: Response) => { + try { + const audit = await service.completeAudit(req.tenantId!, req.params.id); + res.json(audit); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Cancel an audit + * POST /api/assets/audits/:id/cancel + */ + router.post('/:id/cancel', async (req: TenantRequest, res: Response) => { + try { + const audit = await service.cancelAudit(req.tenantId!, req.params.id, req.body.reason); + res.json(audit); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/assets/controllers/asset-maintenance.controller.ts b/src/modules/assets/controllers/asset-maintenance.controller.ts new file mode 100644 index 0000000..6bf1490 --- /dev/null +++ b/src/modules/assets/controllers/asset-maintenance.controller.ts @@ -0,0 +1,189 @@ +/** + * AssetMaintenance Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for asset maintenance. + * Module: MMD-013 Asset Management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AssetMaintenanceService, MaintenanceFilters } from '../services/asset-maintenance.service'; +import { MaintenanceType, MaintenanceStatus } from '../entities/asset-maintenance.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createAssetMaintenanceController(dataSource: DataSource): Router { + const router = Router(); + const service = new AssetMaintenanceService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Schedule a maintenance + * POST /api/assets/maintenance + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.create(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(maintenance); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List maintenance records with filters + * GET /api/assets/maintenance + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: MaintenanceFilters = { + assetId: req.query.assetId as string, + maintenanceType: req.query.type as MaintenanceType, + status: req.query.status as MaintenanceStatus, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get maintenance statistics + * GET /api/assets/maintenance/stats + */ + router.get('/stats', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getStats(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get upcoming maintenance + * GET /api/assets/maintenance/upcoming + */ + router.get('/upcoming', async (req: TenantRequest, res: Response) => { + try { + const daysAhead = parseInt(req.query.days as string, 10) || 30; + const maintenance = await service.findUpcoming(req.tenantId!, daysAhead); + res.json(maintenance); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get overdue maintenance + * GET /api/assets/maintenance/overdue + */ + router.get('/overdue', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.findOverdue(req.tenantId!); + res.json(maintenance); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get maintenance history for an asset + * GET /api/assets/maintenance/asset/:assetId + */ + router.get('/asset/:assetId', async (req: TenantRequest, res: Response) => { + try { + const history = await service.getAssetHistory(req.tenantId!, req.params.assetId); + res.json(history); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get maintenance by ID + * GET /api/assets/maintenance/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.findById(req.tenantId!, req.params.id); + if (!maintenance) { + return res.status(404).json({ error: 'Maintenance record not found' }); + } + res.json(maintenance); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Start maintenance + * POST /api/assets/maintenance/:id/start + */ + router.post('/:id/start', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.startMaintenance(req.tenantId!, req.params.id); + res.json(maintenance); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Complete maintenance + * POST /api/assets/maintenance/:id/complete + */ + router.post('/:id/complete', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.complete(req.tenantId!, req.params.id, { + ...req.body, + performedBy: req.userId || req.body.performedBy, + }); + res.json(maintenance); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Cancel maintenance + * POST /api/assets/maintenance/:id/cancel + */ + router.post('/:id/cancel', async (req: TenantRequest, res: Response) => { + try { + const maintenance = await service.cancel(req.tenantId!, req.params.id, req.body.reason); + res.json(maintenance); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/assets/controllers/asset.controller.ts b/src/modules/assets/controllers/asset.controller.ts new file mode 100644 index 0000000..f74dd25 --- /dev/null +++ b/src/modules/assets/controllers/asset.controller.ts @@ -0,0 +1,281 @@ +/** + * Asset Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for asset management. + * Module: MMD-013 Asset Management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AssetService, AssetFilters } from '../services/asset.service'; +import { AssetStatus, AssetLocation, CriticalityLevel, AssigneeType } from '../entities/asset.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createAssetController(dataSource: DataSource): Router { + const router = Router(); + const service = new AssetService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + // ==================== ASSETS ==================== + + /** + * Create a new asset + * POST /api/assets + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.create(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(asset); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List assets with filters + * GET /api/assets + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: AssetFilters = { + categoryId: req.query.categoryId as string, + status: req.query.status as AssetStatus, + currentLocation: req.query.location as AssetLocation, + criticality: req.query.criticality as CriticalityLevel, + currentAssigneeId: req.query.assigneeId as string, + currentAssigneeType: req.query.assigneeType as AssigneeType, + requiresCalibration: req.query.requiresCalibration === 'true' ? true : req.query.requiresCalibration === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get asset statistics + * GET /api/assets/stats + */ + router.get('/stats', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getStats(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get assets requiring calibration soon + * GET /api/assets/calibration-due + */ + router.get('/calibration-due', async (req: TenantRequest, res: Response) => { + try { + const daysAhead = parseInt(req.query.days as string, 10) || 30; + const assets = await service.findCalibrationDue(req.tenantId!, daysAhead); + res.json(assets); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Find asset by QR code + * GET /api/assets/qr/:qrCode + */ + router.get('/qr/:qrCode', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.findByQrCode(req.tenantId!, req.params.qrCode); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.json(asset); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Find asset by barcode + * GET /api/assets/barcode/:barcode + */ + router.get('/barcode/:barcode', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.findByBarcode(req.tenantId!, req.params.barcode); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.json(asset); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Find asset by code + * GET /api/assets/code/:code + */ + router.get('/code/:code', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.findByCode(req.tenantId!, req.params.code); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.json(asset); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get assets by assignee + * GET /api/assets/assignee/:assigneeId + */ + router.get('/assignee/:assigneeId', async (req: TenantRequest, res: Response) => { + try { + const assigneeType = req.query.type as AssigneeType || AssigneeType.EMPLOYEE; + const assets = await service.findByAssignee(req.tenantId!, req.params.assigneeId, assigneeType); + res.json(assets); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get asset by ID + * GET /api/assets/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.findById(req.tenantId!, req.params.id); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.json(asset); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update asset + * PATCH /api/assets/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const asset = await service.update(req.tenantId!, req.params.id, req.body); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.json(asset); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Retire asset + * DELETE /api/assets/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.retire(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== CATEGORIES ==================== + + /** + * Create a category + * POST /api/assets/categories + */ + router.post('/categories', async (req: TenantRequest, res: Response) => { + try { + const category = await service.createCategory(req.tenantId!, req.body); + res.status(201).json(category); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List all categories + * GET /api/assets/categories + */ + router.get('/categories', async (req: TenantRequest, res: Response) => { + try { + const includeInactive = req.query.includeInactive === 'true'; + const categories = await service.findAllCategories(req.tenantId!, includeInactive); + res.json(categories); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get category by ID + * GET /api/assets/categories/:id + */ + router.get('/categories/:id', async (req: TenantRequest, res: Response) => { + try { + const category = await service.findCategoryById(req.tenantId!, req.params.id); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + res.json(category); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update category + * PATCH /api/assets/categories/:id + */ + router.patch('/categories/:id', async (req: TenantRequest, res: Response) => { + try { + const category = await service.updateCategory(req.tenantId!, req.params.id, req.body); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + res.json(category); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/assets/controllers/index.ts b/src/modules/assets/controllers/index.ts new file mode 100644 index 0000000..16a2185 --- /dev/null +++ b/src/modules/assets/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * Assets Module Controllers + * Module: MMD-013 Asset Management + */ + +export * from './asset.controller'; +export * from './asset-assignment.controller'; +export * from './asset-audit.controller'; +export * from './asset-maintenance.controller'; diff --git a/src/modules/assets/entities/asset-assignment.entity.ts b/src/modules/assets/entities/asset-assignment.entity.ts new file mode 100644 index 0000000..a536d34 --- /dev/null +++ b/src/modules/assets/entities/asset-assignment.entity.ts @@ -0,0 +1,97 @@ +/** + * AssetAssignment Entity + * Mecánicas Diesel - ERP Suite + * + * Tracks asset assignments to employees, units, or locations. + * Module: MMD-013 Asset Management + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Asset, AssigneeType } from './asset.entity'; + +export enum AssignmentStatus { + ACTIVE = 'active', + RETURNED = 'returned', + OVERDUE = 'overdue', + LOST = 'lost', + DAMAGED = 'damaged', +} + +@Entity({ name: 'asset_assignments', schema: 'assets' }) +@Index('idx_asset_assignments_tenant', ['tenantId']) +@Index('idx_asset_assignments_asset', ['assetId']) +@Index('idx_asset_assignments_assignee', ['assigneeId', 'assigneeType']) +@Index('idx_asset_assignments_status', ['status']) +@Index('idx_asset_assignments_incident', ['incidentId']) +export class AssetAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId: string; + + @Column({ name: 'assignee_id', type: 'uuid' }) + assigneeId: string; + + @Column({ name: 'assignee_type', type: 'varchar', length: 20 }) + assigneeType: AssigneeType; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'NOW()' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid' }) + assignedBy: string; + + @Column({ name: 'expected_return_at', type: 'timestamptz', nullable: true }) + expectedReturnAt?: Date; + + @Column({ name: 'actual_return_at', type: 'timestamptz', nullable: true }) + actualReturnAt?: Date; + + @Column({ name: 'returned_to', type: 'uuid', nullable: true }) + returnedTo?: string; + + @Column({ + type: 'varchar', + length: 20, + default: AssignmentStatus.ACTIVE, + }) + status: AssignmentStatus; + + @Column({ name: 'assignment_photo_url', type: 'text', nullable: true }) + assignmentPhotoUrl?: string; + + @Column({ name: 'return_photo_url', type: 'text', nullable: true }) + returnPhotoUrl?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'signature_url', type: 'text', nullable: true }) + signatureUrl?: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Asset, asset => asset.assignments) + @JoinColumn({ name: 'asset_id' }) + asset: Asset; +} diff --git a/src/modules/assets/entities/asset-audit-item.entity.ts b/src/modules/assets/entities/asset-audit-item.entity.ts new file mode 100644 index 0000000..699f230 --- /dev/null +++ b/src/modules/assets/entities/asset-audit-item.entity.ts @@ -0,0 +1,76 @@ +/** + * AssetAuditItem Entity + * Mecánicas Diesel - ERP Suite + * + * Individual items checked during an audit. + * Module: MMD-013 Asset Management + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AssetAudit } from './asset-audit.entity'; +import { Asset } from './asset.entity'; + +export enum AuditItemStatus { + FOUND = 'found', + MISSING = 'missing', + DAMAGED = 'damaged', + EXTRA = 'extra', +} + +export enum ItemCondition { + GOOD = 'good', + FAIR = 'fair', + POOR = 'poor', +} + +@Entity({ name: 'asset_audit_items', schema: 'assets' }) +@Index('idx_asset_audit_items_audit', ['auditId']) +@Index('idx_asset_audit_items_asset', ['assetId']) +@Index('idx_asset_audit_items_status', ['status']) +export class AssetAuditItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'audit_id', type: 'uuid' }) + auditId: string; + + @Column({ name: 'asset_id', type: 'uuid', nullable: true }) + assetId?: string; + + @Column({ + type: 'varchar', + length: 20, + }) + status: AuditItemStatus; + + @Column({ type: 'varchar', length: 10, nullable: true }) + condition?: ItemCondition; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'photo_url', type: 'text', nullable: true }) + photoUrl?: string; + + @Column({ name: 'scanned_at', type: 'timestamptz', default: () => 'NOW()' }) + scannedAt: Date; + + @Column({ name: 'scanned_by', type: 'uuid', nullable: true }) + scannedBy?: string; + + // Relations + @ManyToOne(() => AssetAudit, audit => audit.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'audit_id' }) + audit: AssetAudit; + + @ManyToOne(() => Asset, { nullable: true }) + @JoinColumn({ name: 'asset_id' }) + asset?: Asset; +} diff --git a/src/modules/assets/entities/asset-audit.entity.ts b/src/modules/assets/entities/asset-audit.entity.ts new file mode 100644 index 0000000..bee41ce --- /dev/null +++ b/src/modules/assets/entities/asset-audit.entity.ts @@ -0,0 +1,91 @@ +/** + * AssetAudit Entity + * Mecánicas Diesel - ERP Suite + * + * Physical inventory audits. + * Module: MMD-013 Asset Management + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { AssetAuditItem } from './asset-audit-item.entity'; + +export enum AuditStatus { + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'asset_audits', schema: 'assets' }) +@Index('idx_asset_audits_tenant', ['tenantId']) +@Index('idx_asset_audits_date', ['auditDate']) +@Index('idx_asset_audits_auditor', ['auditorId']) +@Index('idx_asset_audits_status', ['status']) +@Index('idx_asset_audits_unit', ['unitId']) +@Index('idx_asset_audits_technician', ['technicianId']) +export class AssetAudit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'audit_date', type: 'date' }) + auditDate: Date; + + @Column({ name: 'auditor_id', type: 'uuid' }) + auditorId: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'unit_id', type: 'uuid', nullable: true }) + unitId?: string; + + @Column({ name: 'technician_id', type: 'uuid', nullable: true }) + technicianId?: string; + + @Column({ + type: 'varchar', + length: 20, + default: AuditStatus.IN_PROGRESS, + }) + status: AuditStatus; + + @Column({ name: 'total_assets', type: 'integer', default: 0 }) + totalAssets: number; + + @Column({ name: 'found_assets', type: 'integer', default: 0 }) + foundAssets: number; + + @Column({ name: 'missing_assets', type: 'integer', default: 0 }) + missingAssets: number; + + @Column({ name: 'damaged_assets', type: 'integer', default: 0 }) + damagedAssets: number; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @OneToMany(() => AssetAuditItem, item => item.audit) + items: AssetAuditItem[]; +} diff --git a/src/modules/assets/entities/asset-category.entity.ts b/src/modules/assets/entities/asset-category.entity.ts new file mode 100644 index 0000000..4d1f0df --- /dev/null +++ b/src/modules/assets/entities/asset-category.entity.ts @@ -0,0 +1,62 @@ +/** + * AssetCategory Entity + * Mecánicas Diesel - ERP Suite + * + * Hierarchical categorization of assets. + * Module: MMD-013 Asset Management + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'asset_categories', schema: 'assets' }) +@Index('idx_asset_categories_tenant', ['tenantId']) +@Index('idx_asset_categories_parent', ['parentId']) +export class AssetCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId?: string; + + @Column({ name: 'requires_service_types', type: 'text', array: true, nullable: true }) + requiresServiceTypes?: string[]; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => AssetCategory, category => category.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent?: AssetCategory; + + @OneToMany(() => AssetCategory, category => category.parent) + children: AssetCategory[]; +} diff --git a/src/modules/assets/entities/asset-maintenance.entity.ts b/src/modules/assets/entities/asset-maintenance.entity.ts new file mode 100644 index 0000000..305f9b3 --- /dev/null +++ b/src/modules/assets/entities/asset-maintenance.entity.ts @@ -0,0 +1,104 @@ +/** + * AssetMaintenance Entity + * Mecánicas Diesel - ERP Suite + * + * Maintenance records including calibration. + * Module: MMD-013 Asset Management + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Asset } from './asset.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'preventive', + CORRECTIVE = 'corrective', + CALIBRATION = 'calibration', +} + +export enum MaintenanceStatus { + SCHEDULED = 'scheduled', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'asset_maintenance', schema: 'assets' }) +@Index('idx_asset_maintenance_tenant', ['tenantId']) +@Index('idx_asset_maintenance_asset', ['assetId']) +@Index('idx_asset_maintenance_type', ['maintenanceType']) +@Index('idx_asset_maintenance_status', ['status']) +@Index('idx_asset_maintenance_scheduled', ['scheduledDate']) +export class AssetMaintenance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'asset_id', type: 'uuid' }) + assetId: string; + + @Column({ + name: 'maintenance_type', + type: 'varchar', + length: 20, + }) + maintenanceType: MaintenanceType; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate?: Date; + + @Column({ name: 'performed_date', type: 'date', nullable: true }) + performedDate?: Date; + + @Column({ name: 'performed_by', type: 'uuid', nullable: true }) + performedBy?: string; + + @Column({ name: 'external_vendor', type: 'varchar', length: 200, nullable: true }) + externalVendor?: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + cost?: number; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'text', nullable: true }) + result?: string; + + @Column({ name: 'next_maintenance_date', type: 'date', nullable: true }) + nextMaintenanceDate?: Date; + + @Column({ + type: 'varchar', + length: 20, + default: MaintenanceStatus.SCHEDULED, + }) + status: MaintenanceStatus; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + // Relations + @ManyToOne(() => Asset, asset => asset.maintenanceRecords) + @JoinColumn({ name: 'asset_id' }) + asset: Asset; +} diff --git a/src/modules/assets/entities/asset.entity.ts b/src/modules/assets/entities/asset.entity.ts new file mode 100644 index 0000000..bd4c96f --- /dev/null +++ b/src/modules/assets/entities/asset.entity.ts @@ -0,0 +1,163 @@ +/** + * Asset Entity + * Mecánicas Diesel - ERP Suite + * + * Represents trackable assets and tools. + * Module: MMD-013 Asset Management + */ + +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 { AssetMaintenance } from './asset-maintenance.entity'; + +export enum AssetStatus { + AVAILABLE = 'available', + ASSIGNED = 'assigned', + IN_MAINTENANCE = 'in_maintenance', + DAMAGED = 'damaged', + RETIRED = 'retired', +} + +export enum AssetLocation { + WAREHOUSE = 'warehouse', + UNIT = 'unit', + TECHNICIAN = 'technician', + EXTERNAL = 'external', +} + +export enum CriticalityLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +export enum AssigneeType { + EMPLOYEE = 'employee', + UNIT = 'unit', + LOCATION = 'location', +} + +@Entity({ name: 'assets', schema: 'assets' }) +@Index('idx_assets_tenant', ['tenantId']) +@Index('idx_assets_category', ['categoryId']) +@Index('idx_assets_status', ['status']) +@Index('idx_assets_location', ['currentLocation']) +@Index('idx_assets_assignee', ['currentAssigneeId']) +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 150 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'category_id', type: 'uuid' }) + categoryId: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 100, nullable: true }) + serialNumber?: string; + + @Column({ name: 'qr_code', type: 'varchar', length: 100, nullable: true }) + qrCode?: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + barcode?: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + manufacturer?: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + model?: string; + + @Column({ name: 'purchase_date', type: 'date', nullable: true }) + purchaseDate?: Date; + + @Column({ name: 'purchase_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) + purchaseCost?: number; + + @Column({ name: 'warranty_expiry', type: 'date', nullable: true }) + warrantyExpiry?: Date; + + @Column({ + type: 'varchar', + length: 20, + default: AssetStatus.AVAILABLE, + }) + status: AssetStatus; + + @Column({ + name: 'current_location', + type: 'varchar', + length: 20, + default: AssetLocation.WAREHOUSE, + }) + currentLocation: AssetLocation; + + @Column({ name: 'current_assignee_id', type: 'uuid', nullable: true }) + currentAssigneeId?: string; + + @Column({ name: 'current_assignee_type', type: 'varchar', length: 20, nullable: true }) + currentAssigneeType?: AssigneeType; + + @Column({ + type: 'varchar', + length: 20, + default: CriticalityLevel.MEDIUM, + }) + criticality: CriticalityLevel; + + @Column({ name: 'requires_calibration', type: 'boolean', default: false }) + requiresCalibration: boolean; + + @Column({ name: 'last_calibration_date', type: 'date', nullable: true }) + lastCalibrationDate?: Date; + + @Column({ name: 'next_calibration_date', type: 'date', nullable: true }) + nextCalibrationDate?: Date; + + @Column({ name: 'photo_url', type: 'text', nullable: true }) + photoUrl?: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + // Relations + @ManyToOne(() => AssetCategory) + @JoinColumn({ name: 'category_id' }) + category: AssetCategory; + + @OneToMany(() => AssetAssignment, assignment => assignment.asset) + assignments: AssetAssignment[]; + + @OneToMany(() => AssetMaintenance, maintenance => maintenance.asset) + maintenanceRecords: AssetMaintenance[]; +} diff --git a/src/modules/assets/entities/index.ts b/src/modules/assets/entities/index.ts new file mode 100644 index 0000000..05349b6 --- /dev/null +++ b/src/modules/assets/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Assets Module Entities + * Module: MMD-013 Asset Management + */ + +export * from './asset-category.entity'; +export * from './asset.entity'; +export * from './asset-assignment.entity'; +export * from './asset-audit.entity'; +export * from './asset-audit-item.entity'; +export * from './asset-maintenance.entity'; diff --git a/src/modules/assets/index.ts b/src/modules/assets/index.ts new file mode 100644 index 0000000..4c5ed67 --- /dev/null +++ b/src/modules/assets/index.ts @@ -0,0 +1,55 @@ +/** + * Assets Module + * Mecánicas Diesel - ERP Suite + * Module: MMD-013 Asset Management + */ + +// Entities +export { + Asset, + AssetStatus, + AssetLocation, + CriticalityLevel, + AssigneeType, +} from './entities/asset.entity'; +export { AssetCategory } from './entities/asset-category.entity'; +export { AssetAssignment, AssignmentStatus } from './entities/asset-assignment.entity'; +export { AssetAudit, AuditStatus } from './entities/asset-audit.entity'; +export { AssetAuditItem, AuditItemStatus, ItemCondition } from './entities/asset-audit-item.entity'; +export { AssetMaintenance, MaintenanceType, MaintenanceStatus } from './entities/asset-maintenance.entity'; + +// Services +export { + AssetService, + CreateAssetDto, + UpdateAssetDto, + AssetFilters, + CreateCategoryDto, + UpdateCategoryDto, +} from './services/asset.service'; +export { + AssetAssignmentService, + AssignAssetDto, + ReturnAssetDto, + TransferAssetDto, + AssignmentFilters, +} from './services/asset-assignment.service'; +export { + AssetAuditService, + StartAuditDto, + ScanAssetDto, + AuditFilters, +} from './services/asset-audit.service'; +export { + AssetMaintenanceService, + CreateMaintenanceDto, + UpdateMaintenanceDto, + CompleteMaintenanceDto, + MaintenanceFilters, +} from './services/asset-maintenance.service'; + +// Controllers +export { createAssetController } from './controllers/asset.controller'; +export { createAssetAssignmentController } from './controllers/asset-assignment.controller'; +export { createAssetAuditController } from './controllers/asset-audit.controller'; +export { createAssetMaintenanceController } from './controllers/asset-maintenance.controller'; diff --git a/src/modules/assets/services/asset-assignment.service.ts b/src/modules/assets/services/asset-assignment.service.ts new file mode 100644 index 0000000..6518f03 --- /dev/null +++ b/src/modules/assets/services/asset-assignment.service.ts @@ -0,0 +1,381 @@ +/** + * AssetAssignment Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for asset assignment/lending. + * Module: MMD-013 Asset Management + */ + +import { Repository, DataSource } from 'typeorm'; +import { AssetAssignment, AssignmentStatus } from '../entities/asset-assignment.entity'; +import { Asset, AssetStatus, AssetLocation, AssigneeType } from '../entities/asset.entity'; + +// DTOs +export interface AssignAssetDto { + assetId: string; + assigneeId: string; + assigneeType: AssigneeType; + assignedBy: string; + expectedReturnAt?: Date; + assignmentPhotoUrl?: string; + notes?: string; + signatureUrl?: string; + incidentId?: string; + metadata?: Record; +} + +export interface ReturnAssetDto { + returnedTo: string; + returnPhotoUrl?: string; + notes?: string; + condition?: 'good' | 'damaged' | 'lost'; +} + +export interface TransferAssetDto { + newAssigneeId: string; + newAssigneeType: AssigneeType; + transferredBy: string; + notes?: string; +} + +export interface AssignmentFilters { + assetId?: string; + assigneeId?: string; + assigneeType?: AssigneeType; + status?: AssignmentStatus; + incidentId?: string; + startDate?: Date; + endDate?: Date; +} + +export interface ValidationResult { + valid: boolean; + missingAssets: string[]; + availableAssets: string[]; +} + +export class AssetAssignmentService { + private assignmentRepository: Repository; + private assetRepository: Repository; + + constructor(dataSource: DataSource) { + this.assignmentRepository = dataSource.getRepository(AssetAssignment); + this.assetRepository = dataSource.getRepository(Asset); + } + + /** + * Assign an asset to an assignee + */ + async assign(tenantId: string, dto: AssignAssetDto): Promise { + // Check asset exists and is available + const asset = await this.assetRepository.findOne({ + where: { id: dto.assetId, tenantId }, + }); + + if (!asset) { + throw new Error(`Asset ${dto.assetId} not found`); + } + + if (asset.status !== AssetStatus.AVAILABLE) { + throw new Error(`Asset ${asset.code} is not available (current status: ${asset.status})`); + } + + // Create assignment + const assignment = this.assignmentRepository.create({ + tenantId, + assetId: dto.assetId, + assigneeId: dto.assigneeId, + assigneeType: dto.assigneeType, + assignedBy: dto.assignedBy, + expectedReturnAt: dto.expectedReturnAt, + assignmentPhotoUrl: dto.assignmentPhotoUrl, + notes: dto.notes, + signatureUrl: dto.signatureUrl, + incidentId: dto.incidentId, + metadata: dto.metadata || {}, + status: AssignmentStatus.ACTIVE, + }); + + const savedAssignment = await this.assignmentRepository.save(assignment); + + // Update asset status (trigger should handle this, but we update for safety) + await this.assetRepository.update( + { id: dto.assetId }, + { + status: AssetStatus.ASSIGNED, + currentLocation: dto.assigneeType === AssigneeType.UNIT ? AssetLocation.UNIT : AssetLocation.TECHNICIAN, + currentAssigneeId: dto.assigneeId, + currentAssigneeType: dto.assigneeType, + } + ); + + return savedAssignment; + } + + /** + * Return an assigned asset + */ + async return(tenantId: string, assignmentId: string, dto: ReturnAssetDto): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { id: assignmentId, tenantId }, + }); + + if (!assignment) { + throw new Error(`Assignment ${assignmentId} not found`); + } + + if (assignment.status !== AssignmentStatus.ACTIVE) { + throw new Error(`Assignment is not active (current status: ${assignment.status})`); + } + + // Determine return status based on condition + let newStatus: AssignmentStatus; + let assetStatus: AssetStatus; + + switch (dto.condition) { + case 'damaged': + newStatus = AssignmentStatus.DAMAGED; + assetStatus = AssetStatus.DAMAGED; + break; + case 'lost': + newStatus = AssignmentStatus.LOST; + assetStatus = AssetStatus.RETIRED; + break; + default: + newStatus = AssignmentStatus.RETURNED; + assetStatus = AssetStatus.AVAILABLE; + } + + // Update assignment + assignment.status = newStatus; + assignment.actualReturnAt = new Date(); + assignment.returnedTo = dto.returnedTo; + assignment.returnPhotoUrl = dto.returnPhotoUrl; + if (dto.notes) { + assignment.notes = assignment.notes ? `${assignment.notes}\n${dto.notes}` : dto.notes; + } + + const savedAssignment = await this.assignmentRepository.save(assignment); + + // Update asset + await this.assetRepository.update( + { id: assignment.assetId }, + { + status: assetStatus, + currentLocation: AssetLocation.WAREHOUSE, + currentAssigneeId: undefined, + currentAssigneeType: undefined, + } + ); + + return savedAssignment; + } + + /** + * Transfer an asset to a new assignee + */ + async transfer(tenantId: string, assignmentId: string, dto: TransferAssetDto): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { id: assignmentId, tenantId }, + }); + + if (!assignment) { + throw new Error(`Assignment ${assignmentId} not found`); + } + + if (assignment.status !== AssignmentStatus.ACTIVE) { + throw new Error(`Assignment is not active (current status: ${assignment.status})`); + } + + // Close current assignment + assignment.status = AssignmentStatus.RETURNED; + assignment.actualReturnAt = new Date(); + assignment.returnedTo = dto.transferredBy; + assignment.notes = assignment.notes + ? `${assignment.notes}\nTransferred to ${dto.newAssigneeId}` + : `Transferred to ${dto.newAssigneeId}`; + + await this.assignmentRepository.save(assignment); + + // Create new assignment + const newAssignment = this.assignmentRepository.create({ + tenantId, + assetId: assignment.assetId, + assigneeId: dto.newAssigneeId, + assigneeType: dto.newAssigneeType, + assignedBy: dto.transferredBy, + notes: dto.notes || `Transferred from ${assignment.assigneeId}`, + incidentId: assignment.incidentId, + status: AssignmentStatus.ACTIVE, + }); + + const savedAssignment = await this.assignmentRepository.save(newAssignment); + + // Update asset + await this.assetRepository.update( + { id: assignment.assetId }, + { + currentLocation: dto.newAssigneeType === AssigneeType.UNIT ? AssetLocation.UNIT : AssetLocation.TECHNICIAN, + currentAssigneeId: dto.newAssigneeId, + currentAssigneeType: dto.newAssigneeType, + } + ); + + return savedAssignment; + } + + /** + * Get assignment by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.assignmentRepository.findOne({ + where: { id, tenantId }, + relations: ['asset'], + }); + } + + /** + * Get active assignments for an asset + */ + async getActiveAssignments(tenantId: string, assetId: string): Promise { + return this.assignmentRepository.find({ + where: { tenantId, assetId, status: AssignmentStatus.ACTIVE }, + relations: ['asset'], + }); + } + + /** + * List assignments with filters + */ + async findAll( + tenantId: string, + filters: AssignmentFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.assignmentRepository.createQueryBuilder('assignment') + .leftJoinAndSelect('assignment.asset', 'asset') + .where('assignment.tenant_id = :tenantId', { tenantId }); + + if (filters.assetId) { + queryBuilder.andWhere('assignment.asset_id = :assetId', { assetId: filters.assetId }); + } + if (filters.assigneeId) { + queryBuilder.andWhere('assignment.assignee_id = :assigneeId', { assigneeId: filters.assigneeId }); + } + if (filters.assigneeType) { + queryBuilder.andWhere('assignment.assignee_type = :assigneeType', { assigneeType: filters.assigneeType }); + } + if (filters.status) { + queryBuilder.andWhere('assignment.status = :status', { status: filters.status }); + } + if (filters.incidentId) { + queryBuilder.andWhere('assignment.incident_id = :incidentId', { incidentId: filters.incidentId }); + } + if (filters.startDate) { + queryBuilder.andWhere('assignment.assigned_at >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + queryBuilder.andWhere('assignment.assigned_at <= :endDate', { endDate: filters.endDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('assignment.assigned_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get overdue assignments + */ + async findOverdue(tenantId: string): Promise { + const now = new Date(); + + return this.assignmentRepository + .createQueryBuilder('assignment') + .leftJoinAndSelect('assignment.asset', 'asset') + .where('assignment.tenant_id = :tenantId', { tenantId }) + .andWhere('assignment.status = :status', { status: AssignmentStatus.ACTIVE }) + .andWhere('assignment.expected_return_at IS NOT NULL') + .andWhere('assignment.expected_return_at < :now', { now }) + .orderBy('assignment.expected_return_at', 'ASC') + .getMany(); + } + + /** + * Validate required assets for an incident + */ + async validateRequiredAssets( + tenantId: string, + requiredCategoryIds: string[], + assigneeId: string, + assigneeType: AssigneeType + ): Promise { + if (requiredCategoryIds.length === 0) { + return { valid: true, missingAssets: [], availableAssets: [] }; + } + + // Get assets currently assigned to the assignee + const assignedAssets = await this.assetRepository.find({ + where: { + tenantId, + currentAssigneeId: assigneeId, + currentAssigneeType: assigneeType, + status: AssetStatus.ASSIGNED, + }, + }); + + const assignedCategoryIds = new Set(assignedAssets.map(a => a.categoryId)); + const missingAssets: string[] = []; + const availableAssets: string[] = []; + + for (const categoryId of requiredCategoryIds) { + if (assignedCategoryIds.has(categoryId)) { + availableAssets.push(categoryId); + } else { + missingAssets.push(categoryId); + } + } + + return { + valid: missingAssets.length === 0, + missingAssets, + availableAssets, + }; + } + + /** + * Get assignment history for an asset + */ + async getAssetHistory(tenantId: string, assetId: string): Promise { + return this.assignmentRepository.find({ + where: { tenantId, assetId }, + order: { assignedAt: 'DESC' }, + }); + } + + /** + * Get assignment history for an assignee + */ + async getAssigneeHistory( + tenantId: string, + assigneeId: string, + assigneeType: AssigneeType + ): Promise { + return this.assignmentRepository.find({ + where: { tenantId, assigneeId, assigneeType }, + relations: ['asset'], + order: { assignedAt: 'DESC' }, + }); + } +} diff --git a/src/modules/assets/services/asset-audit.service.ts b/src/modules/assets/services/asset-audit.service.ts new file mode 100644 index 0000000..5535807 --- /dev/null +++ b/src/modules/assets/services/asset-audit.service.ts @@ -0,0 +1,387 @@ +/** + * AssetAudit Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for asset inventory audits. + * Module: MMD-013 Asset Management + */ + +import { Repository, DataSource } from 'typeorm'; +import { AssetAudit, AuditStatus } from '../entities/asset-audit.entity'; +import { AssetAuditItem, AuditItemStatus, ItemCondition } from '../entities/asset-audit-item.entity'; +import { Asset, AssetStatus, AssigneeType } from '../entities/asset.entity'; + +// DTOs +export interface StartAuditDto { + auditorId: string; + locationId?: string; + unitId?: string; + technicianId?: string; + notes?: string; + metadata?: Record; +} + +export interface ScanAssetDto { + assetId?: string; + qrCode?: string; + barcode?: string; + status: AuditItemStatus; + condition?: ItemCondition; + notes?: string; + photoUrl?: string; + scannedBy?: string; +} + +export interface AuditFilters { + auditorId?: string; + locationId?: string; + unitId?: string; + technicianId?: string; + status?: AuditStatus; + startDate?: Date; + endDate?: Date; +} + +export interface AuditDiscrepancy { + assetId: string; + assetCode: string; + assetName: string; + status: AuditItemStatus; + condition?: ItemCondition; + notes?: string; +} + +export class AssetAuditService { + private auditRepository: Repository; + private auditItemRepository: Repository; + private assetRepository: Repository; + + constructor(dataSource: DataSource) { + this.auditRepository = dataSource.getRepository(AssetAudit); + this.auditItemRepository = dataSource.getRepository(AssetAuditItem); + this.assetRepository = dataSource.getRepository(Asset); + } + + /** + * Start a new audit + */ + async startAudit(tenantId: string, dto: StartAuditDto): Promise { + // Validate that at least one scope is provided + if (!dto.locationId && !dto.unitId && !dto.technicianId) { + throw new Error('Audit must have a scope: location, unit, or technician'); + } + + // Check for existing in-progress audit with same scope + const existingAudit = await this.auditRepository.findOne({ + where: { + tenantId, + status: AuditStatus.IN_PROGRESS, + locationId: dto.locationId, + unitId: dto.unitId, + technicianId: dto.technicianId, + }, + }); + + if (existingAudit) { + throw new Error('An audit is already in progress for this scope'); + } + + const audit = this.auditRepository.create({ + tenantId, + auditDate: new Date(), + auditorId: dto.auditorId, + locationId: dto.locationId, + unitId: dto.unitId, + technicianId: dto.technicianId, + notes: dto.notes, + metadata: dto.metadata || {}, + status: AuditStatus.IN_PROGRESS, + }); + + return this.auditRepository.save(audit); + } + + /** + * Scan/record an asset in an audit + */ + async scanAsset(tenantId: string, auditId: string, dto: ScanAssetDto): Promise { + const audit = await this.auditRepository.findOne({ + where: { id: auditId, tenantId }, + }); + + if (!audit) { + throw new Error(`Audit ${auditId} not found`); + } + + if (audit.status !== AuditStatus.IN_PROGRESS) { + throw new Error('Audit is not in progress'); + } + + // Find the asset + let assetId = dto.assetId; + if (!assetId && dto.qrCode) { + const asset = await this.assetRepository.findOne({ + where: { tenantId, qrCode: dto.qrCode }, + }); + if (asset) assetId = asset.id; + } + if (!assetId && dto.barcode) { + const asset = await this.assetRepository.findOne({ + where: { tenantId, barcode: dto.barcode }, + }); + if (asset) assetId = asset.id; + } + + // Check if already scanned + if (assetId) { + const existing = await this.auditItemRepository.findOne({ + where: { auditId, assetId }, + }); + if (existing) { + // Update existing scan + existing.status = dto.status; + existing.condition = dto.condition; + existing.notes = dto.notes; + existing.photoUrl = dto.photoUrl; + existing.scannedAt = new Date(); + existing.scannedBy = dto.scannedBy; + return this.auditItemRepository.save(existing); + } + } + + // Create new audit item + const auditItem = this.auditItemRepository.create({ + auditId, + assetId, + status: dto.status, + condition: dto.condition, + notes: dto.notes, + photoUrl: dto.photoUrl, + scannedBy: dto.scannedBy, + }); + + return this.auditItemRepository.save(auditItem); + } + + /** + * Complete an audit + */ + async completeAudit(tenantId: string, auditId: string): Promise { + const audit = await this.auditRepository.findOne({ + where: { id: auditId, tenantId }, + }); + + if (!audit) { + throw new Error(`Audit ${auditId} not found`); + } + + if (audit.status !== AuditStatus.IN_PROGRESS) { + throw new Error('Audit is not in progress'); + } + + // Auto-mark missing assets + await this.markMissingAssets(tenantId, audit); + + // Update audit status + audit.status = AuditStatus.COMPLETED; + audit.completedAt = new Date(); + + return this.auditRepository.save(audit); + } + + /** + * Cancel an audit + */ + async cancelAudit(tenantId: string, auditId: string, reason?: string): Promise { + const audit = await this.auditRepository.findOne({ + where: { id: auditId, tenantId }, + }); + + if (!audit) { + throw new Error(`Audit ${auditId} not found`); + } + + if (audit.status === AuditStatus.COMPLETED) { + throw new Error('Cannot cancel a completed audit'); + } + + audit.status = AuditStatus.CANCELLED; + if (reason) { + audit.notes = audit.notes ? `${audit.notes}\nCancellation reason: ${reason}` : `Cancellation reason: ${reason}`; + } + + return this.auditRepository.save(audit); + } + + /** + * Mark assets not scanned as missing + */ + private async markMissingAssets(tenantId: string, audit: AssetAudit): Promise { + // Get expected assets based on audit scope + let expectedAssets: Asset[] = []; + + if (audit.technicianId) { + expectedAssets = await this.assetRepository.find({ + where: { + tenantId, + currentAssigneeId: audit.technicianId, + currentAssigneeType: AssigneeType.EMPLOYEE, + status: AssetStatus.ASSIGNED, + }, + }); + } else if (audit.unitId) { + expectedAssets = await this.assetRepository.find({ + where: { + tenantId, + currentAssigneeId: audit.unitId, + currentAssigneeType: AssigneeType.UNIT, + status: AssetStatus.ASSIGNED, + }, + }); + } + + // Get already scanned asset IDs + const scannedItems = await this.auditItemRepository.find({ + where: { auditId: audit.id }, + }); + const scannedAssetIds = new Set(scannedItems.filter(i => i.assetId).map(i => i.assetId)); + + // Mark missing assets + for (const asset of expectedAssets) { + if (!scannedAssetIds.has(asset.id)) { + const missingItem = this.auditItemRepository.create({ + auditId: audit.id, + assetId: asset.id, + status: AuditItemStatus.MISSING, + notes: 'Auto-marked as missing - not scanned during audit', + }); + await this.auditItemRepository.save(missingItem); + } + } + } + + /** + * Get audit by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.auditRepository.findOne({ + where: { id, tenantId }, + relations: ['items'], + }); + } + + /** + * List audits with filters + */ + async findAll( + tenantId: string, + filters: AuditFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.auditRepository.createQueryBuilder('audit') + .where('audit.tenant_id = :tenantId', { tenantId }); + + if (filters.auditorId) { + queryBuilder.andWhere('audit.auditor_id = :auditorId', { auditorId: filters.auditorId }); + } + if (filters.locationId) { + queryBuilder.andWhere('audit.location_id = :locationId', { locationId: filters.locationId }); + } + if (filters.unitId) { + queryBuilder.andWhere('audit.unit_id = :unitId', { unitId: filters.unitId }); + } + if (filters.technicianId) { + queryBuilder.andWhere('audit.technician_id = :technicianId', { technicianId: filters.technicianId }); + } + if (filters.status) { + queryBuilder.andWhere('audit.status = :status', { status: filters.status }); + } + if (filters.startDate) { + queryBuilder.andWhere('audit.audit_date >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + queryBuilder.andWhere('audit.audit_date <= :endDate', { endDate: filters.endDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('audit.audit_date', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get audit items for an audit + */ + async getAuditItems(auditId: string): Promise { + return this.auditItemRepository.find({ + where: { auditId }, + relations: ['asset'], + order: { scannedAt: 'DESC' }, + }); + } + + /** + * Get discrepancies (missing, damaged, extra) + */ + async getDiscrepancies(tenantId: string, auditId: string): Promise { + const items = await this.auditItemRepository + .createQueryBuilder('item') + .leftJoinAndSelect('item.asset', 'asset') + .where('item.audit_id = :auditId', { auditId }) + .andWhere('item.status != :found', { found: AuditItemStatus.FOUND }) + .getMany(); + + return items.map(item => ({ + assetId: item.assetId || 'unknown', + assetCode: item.asset?.code || 'unknown', + assetName: item.asset?.name || 'Unknown asset', + status: item.status, + condition: item.condition, + notes: item.notes, + })); + } + + /** + * Get audit summary statistics + */ + async getAuditSummary(auditId: string): Promise<{ + totalScanned: number; + found: number; + missing: number; + damaged: number; + extra: number; + accuracyRate: number; + }> { + const items = await this.auditItemRepository.find({ + where: { auditId }, + }); + + const found = items.filter(i => i.status === AuditItemStatus.FOUND).length; + const missing = items.filter(i => i.status === AuditItemStatus.MISSING).length; + const damaged = items.filter(i => i.status === AuditItemStatus.DAMAGED).length; + const extra = items.filter(i => i.status === AuditItemStatus.EXTRA).length; + const totalScanned = items.length; + const expected = found + missing + damaged; // Excludes extra + const accuracyRate = expected > 0 ? (found / expected) * 100 : 100; + + return { + totalScanned, + found, + missing, + damaged, + extra, + accuracyRate: Math.round(accuracyRate * 100) / 100, + }; + } +} diff --git a/src/modules/assets/services/asset-maintenance.service.ts b/src/modules/assets/services/asset-maintenance.service.ts new file mode 100644 index 0000000..1e6c27d --- /dev/null +++ b/src/modules/assets/services/asset-maintenance.service.ts @@ -0,0 +1,364 @@ +/** + * AssetMaintenance Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for asset maintenance tracking. + * Module: MMD-013 Asset Management + */ + +import { Repository, DataSource, LessThanOrEqual } from 'typeorm'; +import { AssetMaintenance, MaintenanceType, MaintenanceStatus } from '../entities/asset-maintenance.entity'; +import { Asset, AssetStatus } from '../entities/asset.entity'; + +// DTOs +export interface CreateMaintenanceDto { + assetId: string; + maintenanceType: MaintenanceType; + scheduledDate?: Date; + description: string; + externalVendor?: string; + metadata?: Record; + createdBy?: string; +} + +export interface UpdateMaintenanceDto { + scheduledDate?: Date; + description?: string; + externalVendor?: string; + cost?: number; + result?: string; + nextMaintenanceDate?: Date; + metadata?: Record; +} + +export interface CompleteMaintenanceDto { + performedBy: string; + cost?: number; + result?: string; + nextMaintenanceDate?: Date; +} + +export interface MaintenanceFilters { + assetId?: string; + maintenanceType?: MaintenanceType; + status?: MaintenanceStatus; + startDate?: Date; + endDate?: Date; +} + +export class AssetMaintenanceService { + private maintenanceRepository: Repository; + private assetRepository: Repository; + + constructor(dataSource: DataSource) { + this.maintenanceRepository = dataSource.getRepository(AssetMaintenance); + this.assetRepository = dataSource.getRepository(Asset); + } + + /** + * Schedule a maintenance + */ + async create(tenantId: string, dto: CreateMaintenanceDto): Promise { + // Validate asset exists + const asset = await this.assetRepository.findOne({ + where: { id: dto.assetId, tenantId }, + }); + + if (!asset) { + throw new Error(`Asset ${dto.assetId} not found`); + } + + if (asset.status === AssetStatus.RETIRED) { + throw new Error('Cannot schedule maintenance for a retired asset'); + } + + const maintenance = this.maintenanceRepository.create({ + tenantId, + assetId: dto.assetId, + maintenanceType: dto.maintenanceType, + scheduledDate: dto.scheduledDate, + description: dto.description, + externalVendor: dto.externalVendor, + metadata: dto.metadata || {}, + createdBy: dto.createdBy, + status: dto.scheduledDate ? MaintenanceStatus.SCHEDULED : MaintenanceStatus.IN_PROGRESS, + }); + + return this.maintenanceRepository.save(maintenance); + } + + /** + * Start maintenance (puts asset in maintenance status) + */ + async startMaintenance(tenantId: string, id: string): Promise { + const maintenance = await this.maintenanceRepository.findOne({ + where: { id, tenantId }, + }); + + if (!maintenance) { + throw new Error(`Maintenance record ${id} not found`); + } + + if (maintenance.status !== MaintenanceStatus.SCHEDULED) { + throw new Error(`Cannot start maintenance with status ${maintenance.status}`); + } + + // Update maintenance status + maintenance.status = MaintenanceStatus.IN_PROGRESS; + await this.maintenanceRepository.save(maintenance); + + // Update asset status + await this.assetRepository.update( + { id: maintenance.assetId }, + { status: AssetStatus.IN_MAINTENANCE } + ); + + return maintenance; + } + + /** + * Complete a maintenance + */ + async complete(tenantId: string, id: string, dto: CompleteMaintenanceDto): Promise { + const maintenance = await this.maintenanceRepository.findOne({ + where: { id, tenantId }, + }); + + if (!maintenance) { + throw new Error(`Maintenance record ${id} not found`); + } + + if (maintenance.status === MaintenanceStatus.COMPLETED || maintenance.status === MaintenanceStatus.CANCELLED) { + throw new Error(`Cannot complete maintenance with status ${maintenance.status}`); + } + + // Update maintenance + maintenance.status = MaintenanceStatus.COMPLETED; + maintenance.performedDate = new Date(); + maintenance.performedBy = dto.performedBy; + maintenance.cost = dto.cost; + maintenance.result = dto.result; + maintenance.nextMaintenanceDate = dto.nextMaintenanceDate; + + await this.maintenanceRepository.save(maintenance); + + // Update asset + const assetUpdate: Partial = { + status: AssetStatus.AVAILABLE, + }; + + if (maintenance.maintenanceType === MaintenanceType.CALIBRATION) { + assetUpdate.lastCalibrationDate = new Date(); + if (dto.nextMaintenanceDate) { + assetUpdate.nextCalibrationDate = dto.nextMaintenanceDate; + } + } + + await this.assetRepository.update({ id: maintenance.assetId }, assetUpdate); + + return maintenance; + } + + /** + * Cancel a maintenance + */ + async cancel(tenantId: string, id: string, reason?: string): Promise { + const maintenance = await this.maintenanceRepository.findOne({ + where: { id, tenantId }, + }); + + if (!maintenance) { + throw new Error(`Maintenance record ${id} not found`); + } + + if (maintenance.status === MaintenanceStatus.COMPLETED) { + throw new Error('Cannot cancel a completed maintenance'); + } + + maintenance.status = MaintenanceStatus.CANCELLED; + if (reason) { + maintenance.result = reason; + } + + // If asset was in maintenance, return to available + const asset = await this.assetRepository.findOne({ + where: { id: maintenance.assetId }, + }); + + if (asset && asset.status === AssetStatus.IN_MAINTENANCE) { + await this.assetRepository.update( + { id: maintenance.assetId }, + { status: AssetStatus.AVAILABLE } + ); + } + + return this.maintenanceRepository.save(maintenance); + } + + /** + * Get maintenance by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.maintenanceRepository.findOne({ + where: { id, tenantId }, + relations: ['asset'], + }); + } + + /** + * List maintenance records with filters + */ + async findAll( + tenantId: string, + filters: MaintenanceFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.maintenanceRepository.createQueryBuilder('maintenance') + .leftJoinAndSelect('maintenance.asset', 'asset') + .where('maintenance.tenant_id = :tenantId', { tenantId }); + + if (filters.assetId) { + queryBuilder.andWhere('maintenance.asset_id = :assetId', { assetId: filters.assetId }); + } + if (filters.maintenanceType) { + queryBuilder.andWhere('maintenance.maintenance_type = :maintenanceType', { maintenanceType: filters.maintenanceType }); + } + if (filters.status) { + queryBuilder.andWhere('maintenance.status = :status', { status: filters.status }); + } + if (filters.startDate) { + queryBuilder.andWhere('maintenance.scheduled_date >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + queryBuilder.andWhere('maintenance.scheduled_date <= :endDate', { endDate: filters.endDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('maintenance.scheduled_date', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get upcoming scheduled maintenance + */ + async findUpcoming(tenantId: string, daysAhead: number = 30): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + daysAhead); + + return this.maintenanceRepository + .createQueryBuilder('maintenance') + .leftJoinAndSelect('maintenance.asset', 'asset') + .where('maintenance.tenant_id = :tenantId', { tenantId }) + .andWhere('maintenance.status = :status', { status: MaintenanceStatus.SCHEDULED }) + .andWhere('maintenance.scheduled_date <= :futureDate', { futureDate }) + .orderBy('maintenance.scheduled_date', 'ASC') + .getMany(); + } + + /** + * Get overdue maintenance + */ + async findOverdue(tenantId: string): Promise { + const today = new Date(); + + return this.maintenanceRepository + .createQueryBuilder('maintenance') + .leftJoinAndSelect('maintenance.asset', 'asset') + .where('maintenance.tenant_id = :tenantId', { tenantId }) + .andWhere('maintenance.status = :status', { status: MaintenanceStatus.SCHEDULED }) + .andWhere('maintenance.scheduled_date < :today', { today }) + .orderBy('maintenance.scheduled_date', 'ASC') + .getMany(); + } + + /** + * Get maintenance history for an asset + */ + async getAssetHistory(tenantId: string, assetId: string): Promise { + return this.maintenanceRepository.find({ + where: { tenantId, assetId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Get maintenance statistics + */ + async getStats(tenantId: string): Promise<{ + scheduled: number; + inProgress: number; + completedThisMonth: number; + overdue: number; + totalCostThisMonth: number; + byType: Record; + }> { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + + const [scheduled, inProgress, completedThisMonth, overdue, typeCounts, costSum] = await Promise.all([ + this.maintenanceRepository.count({ + where: { tenantId, status: MaintenanceStatus.SCHEDULED }, + }), + this.maintenanceRepository.count({ + where: { tenantId, status: MaintenanceStatus.IN_PROGRESS }, + }), + this.maintenanceRepository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId }) + .andWhere('m.status = :status', { status: MaintenanceStatus.COMPLETED }) + .andWhere('m.performed_date >= :startOfMonth', { startOfMonth }) + .getCount(), + this.maintenanceRepository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId }) + .andWhere('m.status = :status', { status: MaintenanceStatus.SCHEDULED }) + .andWhere('m.scheduled_date < :today', { today }) + .getCount(), + this.maintenanceRepository + .createQueryBuilder('m') + .select('m.maintenance_type', 'type') + .addSelect('COUNT(*)', 'count') + .where('m.tenant_id = :tenantId', { tenantId }) + .groupBy('m.maintenance_type') + .getRawMany(), + this.maintenanceRepository + .createQueryBuilder('m') + .select('SUM(m.cost)', 'total') + .where('m.tenant_id = :tenantId', { tenantId }) + .andWhere('m.status = :status', { status: MaintenanceStatus.COMPLETED }) + .andWhere('m.performed_date >= :startOfMonth', { startOfMonth }) + .getRawOne(), + ]); + + const byType: Record = { + [MaintenanceType.PREVENTIVE]: 0, + [MaintenanceType.CORRECTIVE]: 0, + [MaintenanceType.CALIBRATION]: 0, + }; + + for (const row of typeCounts) { + if (row.type) byType[row.type as MaintenanceType] = parseInt(row.count, 10); + } + + return { + scheduled, + inProgress, + completedThisMonth, + overdue, + totalCostThisMonth: parseFloat(costSum?.total) || 0, + byType, + }; + } +} diff --git a/src/modules/assets/services/asset.service.ts b/src/modules/assets/services/asset.service.ts new file mode 100644 index 0000000..94260c3 --- /dev/null +++ b/src/modules/assets/services/asset.service.ts @@ -0,0 +1,483 @@ +/** + * Asset Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for asset management. + * Module: MMD-013 Asset Management + */ + +import { Repository, DataSource } from 'typeorm'; +import { + Asset, + AssetStatus, + AssetLocation, + CriticalityLevel, + AssigneeType, +} from '../entities/asset.entity'; +import { AssetCategory } from '../entities/asset-category.entity'; + +// DTOs +export interface CreateAssetDto { + code: string; + name: string; + description?: string; + categoryId: string; + serialNumber?: string; + qrCode?: string; + barcode?: string; + manufacturer?: string; + model?: string; + purchaseDate?: Date; + purchaseCost?: number; + warrantyExpiry?: Date; + criticality?: CriticalityLevel; + requiresCalibration?: boolean; + nextCalibrationDate?: Date; + photoUrl?: string; + metadata?: Record; + createdBy?: string; +} + +export interface UpdateAssetDto { + name?: string; + description?: string; + categoryId?: string; + serialNumber?: string; + qrCode?: string; + barcode?: string; + manufacturer?: string; + model?: string; + purchaseDate?: Date; + purchaseCost?: number; + warrantyExpiry?: Date; + status?: AssetStatus; + currentLocation?: AssetLocation; + criticality?: CriticalityLevel; + requiresCalibration?: boolean; + lastCalibrationDate?: Date; + nextCalibrationDate?: Date; + photoUrl?: string; + metadata?: Record; +} + +export interface AssetFilters { + categoryId?: string; + status?: AssetStatus; + currentLocation?: AssetLocation; + criticality?: CriticalityLevel; + currentAssigneeId?: string; + currentAssigneeType?: AssigneeType; + requiresCalibration?: boolean; + search?: string; +} + +export interface CreateCategoryDto { + name: string; + description?: string; + parentId?: string; + requiresServiceTypes?: string[]; + sortOrder?: number; +} + +export interface UpdateCategoryDto { + name?: string; + description?: string; + parentId?: string; + requiresServiceTypes?: string[]; + sortOrder?: number; + isActive?: boolean; +} + +export class AssetService { + private assetRepository: Repository; + private categoryRepository: Repository; + + constructor(dataSource: DataSource) { + this.assetRepository = dataSource.getRepository(Asset); + this.categoryRepository = dataSource.getRepository(AssetCategory); + } + + // ==================== ASSETS ==================== + + /** + * Create a new asset + */ + async create(tenantId: string, dto: CreateAssetDto): Promise { + // Check for duplicate code + const existingCode = await this.assetRepository.findOne({ + where: { tenantId, code: dto.code }, + }); + if (existingCode) { + throw new Error(`Asset with code ${dto.code} already exists`); + } + + // Check for duplicate QR code if provided + if (dto.qrCode) { + const existingQr = await this.assetRepository.findOne({ + where: { tenantId, qrCode: dto.qrCode }, + }); + if (existingQr) { + throw new Error(`Asset with QR code ${dto.qrCode} already exists`); + } + } + + const asset = this.assetRepository.create({ + tenantId, + code: dto.code, + name: dto.name, + description: dto.description, + categoryId: dto.categoryId, + serialNumber: dto.serialNumber, + qrCode: dto.qrCode, + barcode: dto.barcode, + manufacturer: dto.manufacturer, + model: dto.model, + purchaseDate: dto.purchaseDate, + purchaseCost: dto.purchaseCost, + warrantyExpiry: dto.warrantyExpiry, + criticality: dto.criticality || CriticalityLevel.MEDIUM, + requiresCalibration: dto.requiresCalibration || false, + nextCalibrationDate: dto.nextCalibrationDate, + photoUrl: dto.photoUrl, + metadata: dto.metadata || {}, + createdBy: dto.createdBy, + status: AssetStatus.AVAILABLE, + currentLocation: AssetLocation.WAREHOUSE, + }); + + return this.assetRepository.save(asset); + } + + /** + * Find asset by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.assetRepository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + /** + * Find asset by code + */ + async findByCode(tenantId: string, code: string): Promise { + return this.assetRepository.findOne({ + where: { tenantId, code }, + relations: ['category'], + }); + } + + /** + * Find asset by QR code + */ + async findByQrCode(tenantId: string, qrCode: string): Promise { + return this.assetRepository.findOne({ + where: { tenantId, qrCode }, + relations: ['category'], + }); + } + + /** + * Find asset by barcode + */ + async findByBarcode(tenantId: string, barcode: string): Promise { + return this.assetRepository.findOne({ + where: { tenantId, barcode }, + relations: ['category'], + }); + } + + /** + * List assets with filters + */ + async findAll( + tenantId: string, + filters: AssetFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.assetRepository.createQueryBuilder('asset') + .leftJoinAndSelect('asset.category', 'category') + .where('asset.tenant_id = :tenantId', { tenantId }); + + if (filters.categoryId) { + queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: filters.categoryId }); + } + if (filters.status) { + queryBuilder.andWhere('asset.status = :status', { status: filters.status }); + } + if (filters.currentLocation) { + queryBuilder.andWhere('asset.current_location = :currentLocation', { currentLocation: filters.currentLocation }); + } + if (filters.criticality) { + queryBuilder.andWhere('asset.criticality = :criticality', { criticality: filters.criticality }); + } + if (filters.currentAssigneeId) { + queryBuilder.andWhere('asset.current_assignee_id = :currentAssigneeId', { currentAssigneeId: filters.currentAssigneeId }); + } + if (filters.currentAssigneeType) { + queryBuilder.andWhere('asset.current_assignee_type = :currentAssigneeType', { currentAssigneeType: filters.currentAssigneeType }); + } + if (filters.requiresCalibration !== undefined) { + queryBuilder.andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: filters.requiresCalibration }); + } + if (filters.search) { + queryBuilder.andWhere( + '(asset.code ILIKE :search OR asset.name ILIKE :search OR asset.serial_number ILIKE :search OR asset.qr_code ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('asset.created_at', 'DESC') + .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): Promise { + const asset = await this.findById(tenantId, id); + if (!asset) return null; + + // Check QR code uniqueness if changing + if (dto.qrCode && dto.qrCode !== asset.qrCode) { + const existing = await this.findByQrCode(tenantId, dto.qrCode); + if (existing) { + throw new Error(`Asset with QR code ${dto.qrCode} already exists`); + } + } + + Object.assign(asset, dto); + return this.assetRepository.save(asset); + } + + /** + * Retire asset + */ + async retire(tenantId: string, id: string): Promise { + const asset = await this.findById(tenantId, id); + if (!asset) return false; + + if (asset.status === AssetStatus.ASSIGNED) { + throw new Error('Cannot retire an assigned asset'); + } + + asset.status = AssetStatus.RETIRED; + await this.assetRepository.save(asset); + return true; + } + + /** + * Get assets by assignee + */ + async findByAssignee(tenantId: string, assigneeId: string, assigneeType: AssigneeType): Promise { + return this.assetRepository.find({ + where: { + tenantId, + currentAssigneeId: assigneeId, + currentAssigneeType: assigneeType, + status: AssetStatus.ASSIGNED, + }, + relations: ['category'], + order: { name: 'ASC' }, + }); + } + + /** + * Get assets requiring calibration soon + */ + async findCalibrationDue(tenantId: string, daysAhead: number = 30): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + daysAhead); + + return this.assetRepository + .createQueryBuilder('asset') + .leftJoinAndSelect('asset.category', 'category') + .where('asset.tenant_id = :tenantId', { tenantId }) + .andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: true }) + .andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED }) + .andWhere('asset.next_calibration_date <= :futureDate', { futureDate }) + .orderBy('asset.next_calibration_date', 'ASC') + .getMany(); + } + + /** + * Get asset statistics + */ + async getStats(tenantId: string): Promise<{ + total: number; + byStatus: Record; + byLocation: Record; + byCriticality: Record; + calibrationDue: number; + warrantyExpiring: number; + }> { + const now = new Date(); + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const [total, statusCounts, locationCounts, criticalityCounts, calibrationDue, warrantyExpiring] = await Promise.all([ + this.assetRepository.count({ where: { tenantId } }), + this.assetRepository + .createQueryBuilder('asset') + .select('asset.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('asset.tenant_id = :tenantId', { tenantId }) + .groupBy('asset.status') + .getRawMany(), + this.assetRepository + .createQueryBuilder('asset') + .select('asset.current_location', 'location') + .addSelect('COUNT(*)', 'count') + .where('asset.tenant_id = :tenantId', { tenantId }) + .groupBy('asset.current_location') + .getRawMany(), + this.assetRepository + .createQueryBuilder('asset') + .select('asset.criticality', 'criticality') + .addSelect('COUNT(*)', 'count') + .where('asset.tenant_id = :tenantId', { tenantId }) + .groupBy('asset.criticality') + .getRawMany(), + this.assetRepository + .createQueryBuilder('asset') + .where('asset.tenant_id = :tenantId', { tenantId }) + .andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: true }) + .andWhere('asset.next_calibration_date <= :futureDate', { futureDate: thirtyDaysFromNow }) + .andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED }) + .getCount(), + this.assetRepository + .createQueryBuilder('asset') + .where('asset.tenant_id = :tenantId', { tenantId }) + .andWhere('asset.warranty_expiry <= :futureDate', { futureDate: thirtyDaysFromNow }) + .andWhere('asset.warranty_expiry >= :now', { now }) + .andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED }) + .getCount(), + ]); + + const byStatus: Record = { + [AssetStatus.AVAILABLE]: 0, + [AssetStatus.ASSIGNED]: 0, + [AssetStatus.IN_MAINTENANCE]: 0, + [AssetStatus.DAMAGED]: 0, + [AssetStatus.RETIRED]: 0, + }; + + const byLocation: Record = { + [AssetLocation.WAREHOUSE]: 0, + [AssetLocation.UNIT]: 0, + [AssetLocation.TECHNICIAN]: 0, + [AssetLocation.EXTERNAL]: 0, + }; + + const byCriticality: Record = { + [CriticalityLevel.LOW]: 0, + [CriticalityLevel.MEDIUM]: 0, + [CriticalityLevel.HIGH]: 0, + [CriticalityLevel.CRITICAL]: 0, + }; + + for (const row of statusCounts) { + if (row.status) byStatus[row.status as AssetStatus] = parseInt(row.count, 10); + } + for (const row of locationCounts) { + if (row.location) byLocation[row.location as AssetLocation] = parseInt(row.count, 10); + } + for (const row of criticalityCounts) { + if (row.criticality) byCriticality[row.criticality as CriticalityLevel] = parseInt(row.count, 10); + } + + return { + total, + byStatus, + byLocation, + byCriticality, + calibrationDue, + warrantyExpiring, + }; + } + + // ==================== CATEGORIES ==================== + + /** + * Create a category + */ + async createCategory(tenantId: string, dto: CreateCategoryDto): Promise { + const existing = await this.categoryRepository.findOne({ + where: { tenantId, name: dto.name }, + }); + if (existing) { + throw new Error(`Category ${dto.name} already exists`); + } + + const category = this.categoryRepository.create({ + tenantId, + name: dto.name, + description: dto.description, + parentId: dto.parentId, + requiresServiceTypes: dto.requiresServiceTypes, + sortOrder: dto.sortOrder || 0, + isActive: true, + }); + + return this.categoryRepository.save(category); + } + + /** + * Get all categories + */ + async findAllCategories(tenantId: string, includeInactive: boolean = false): Promise { + const where: any = { tenantId }; + if (!includeInactive) { + where.isActive = true; + } + + return this.categoryRepository.find({ + where, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + /** + * Get category by ID + */ + async findCategoryById(tenantId: string, id: string): Promise { + return this.categoryRepository.findOne({ + where: { id, tenantId }, + relations: ['parent', 'children'], + }); + } + + /** + * Update category + */ + async updateCategory(tenantId: string, id: string, dto: UpdateCategoryDto): Promise { + const category = await this.findCategoryById(tenantId, id); + if (!category) return null; + + if (dto.name && dto.name !== category.name) { + const existing = await this.categoryRepository.findOne({ + where: { tenantId, name: dto.name }, + }); + if (existing) { + throw new Error(`Category ${dto.name} already exists`); + } + } + + Object.assign(category, dto); + return this.categoryRepository.save(category); + } +} diff --git a/src/modules/assets/services/index.ts b/src/modules/assets/services/index.ts new file mode 100644 index 0000000..123828e --- /dev/null +++ b/src/modules/assets/services/index.ts @@ -0,0 +1,9 @@ +/** + * Assets Module Services + * Module: MMD-013 Asset Management + */ + +export * from './asset.service'; +export * from './asset-assignment.service'; +export * from './asset-audit.service'; +export * from './asset-maintenance.service';