From 7e0d4ee841a08184ae12a9b0001668ab03183253 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 02:21:40 -0600 Subject: [PATCH] feat(MMD-011): Implement Dispatch Center module Add complete dispatch module for incident assignment: Entities (7): - DispatchBoard: Board configuration - UnitStatus: Real-time unit state - TechnicianSkill: Skills and certifications - TechnicianShift: Shift schedules - DispatchRule: Assignment rules - EscalationRule: Escalation rules - DispatchLog: Audit trail Services (4): - DispatchService: Unit management, assignments, suggestions - SkillService: Skill CRUD, validation, matrix - ShiftService: Shift management, availability - RuleService: Dispatch and escalation rules Controllers (4): - DispatchController: /api/v1/dispatch - SkillController: /api/v1/dispatch/skills - ShiftController: /api/v1/dispatch/shifts - RuleController: /api/v1/dispatch/rules Integrated in main.ts with routes and documentation Co-Authored-By: Claude Opus 4.5 --- src/main.ts | 36 ++ .../controllers/dispatch.controller.ts | 362 ++++++++++++ src/modules/dispatch/controllers/index.ts | 11 + .../dispatch/controllers/rule.controller.ts | 259 +++++++++ .../dispatch/controllers/shift.controller.ts | 304 ++++++++++ .../dispatch/controllers/skill.controller.ts | 223 ++++++++ .../entities/dispatch-board.entity.ts | 71 +++ .../dispatch/entities/dispatch-log.entity.ts | 90 +++ .../dispatch/entities/dispatch-rule.entity.ts | 76 +++ .../entities/escalation-rule.entity.ts | 91 +++ src/modules/dispatch/entities/index.ts | 14 + .../entities/technician-shift.entity.ts | 93 +++ .../entities/technician-skill.entity.ts | 85 +++ .../dispatch/entities/unit-status.entity.ts | 111 ++++ src/modules/dispatch/index.ts | 15 + .../dispatch/services/dispatch.service.ts | 540 ++++++++++++++++++ src/modules/dispatch/services/index.ts | 11 + src/modules/dispatch/services/rule.service.ts | 370 ++++++++++++ .../dispatch/services/shift.service.ts | 377 ++++++++++++ .../dispatch/services/skill.service.ts | 353 ++++++++++++ 20 files changed, 3492 insertions(+) create mode 100644 src/modules/dispatch/controllers/dispatch.controller.ts create mode 100644 src/modules/dispatch/controllers/index.ts create mode 100644 src/modules/dispatch/controllers/rule.controller.ts create mode 100644 src/modules/dispatch/controllers/shift.controller.ts create mode 100644 src/modules/dispatch/controllers/skill.controller.ts create mode 100644 src/modules/dispatch/entities/dispatch-board.entity.ts create mode 100644 src/modules/dispatch/entities/dispatch-log.entity.ts create mode 100644 src/modules/dispatch/entities/dispatch-rule.entity.ts create mode 100644 src/modules/dispatch/entities/escalation-rule.entity.ts create mode 100644 src/modules/dispatch/entities/index.ts create mode 100644 src/modules/dispatch/entities/technician-shift.entity.ts create mode 100644 src/modules/dispatch/entities/technician-skill.entity.ts create mode 100644 src/modules/dispatch/entities/unit-status.entity.ts create mode 100644 src/modules/dispatch/index.ts create mode 100644 src/modules/dispatch/services/dispatch.service.ts create mode 100644 src/modules/dispatch/services/index.ts create mode 100644 src/modules/dispatch/services/rule.service.ts create mode 100644 src/modules/dispatch/services/shift.service.ts create mode 100644 src/modules/dispatch/services/skill.service.ts diff --git a/src/main.ts b/src/main.ts index 6ff73c7..d9b9127 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,12 @@ import { createAssetAssignmentController } from './modules/assets/controllers/as import { createAssetAuditController } from './modules/assets/controllers/asset-audit.controller'; import { createAssetMaintenanceController } from './modules/assets/controllers/asset-maintenance.controller'; +// Dispatch Module Controllers +import { createDispatchController } from './modules/dispatch/controllers/dispatch.controller'; +import { createSkillController } from './modules/dispatch/controllers/skill.controller'; +import { createShiftController } from './modules/dispatch/controllers/shift.controller'; +import { createRuleController } from './modules/dispatch/controllers/rule.controller'; + // Payment Terminals Module import { PaymentTerminalsModule } from './modules/payment-terminals'; @@ -84,6 +90,15 @@ 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'; +// Entities - Dispatch +import { DispatchBoard } from './modules/dispatch/entities/dispatch-board.entity'; +import { UnitStatus } from './modules/dispatch/entities/unit-status.entity'; +import { TechnicianSkill } from './modules/dispatch/entities/technician-skill.entity'; +import { TechnicianShift } from './modules/dispatch/entities/technician-shift.entity'; +import { DispatchRule } from './modules/dispatch/entities/dispatch-rule.entity'; +import { EscalationRule } from './modules/dispatch/entities/escalation-rule.entity'; +import { DispatchLog } from './modules/dispatch/entities/dispatch-log.entity'; + // Load environment variables config(); @@ -141,6 +156,14 @@ const AppDataSource = new DataSource({ AssetAudit, AssetAuditItem, AssetMaintenance, + // Dispatch + DispatchBoard, + UnitStatus, + TechnicianSkill, + TechnicianShift, + DispatchRule, + EscalationRule, + DispatchLog, ], synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', @@ -201,6 +224,13 @@ async function bootstrap() { app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource)); console.log('📦 Assets module initialized'); + // Dispatch Module Routes + app.use('/api/v1/dispatch', createDispatchController(AppDataSource)); + app.use('/api/v1/dispatch/skills', createSkillController(AppDataSource)); + app.use('/api/v1/dispatch/shifts', createShiftController(AppDataSource)); + app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource)); + console.log('📋 Dispatch module initialized'); + // Payment Terminals Module const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); app.use('/api/v1', paymentTerminals.router); @@ -238,6 +268,12 @@ async function bootstrap() { audits: '/api/v1/assets/audits', maintenance: '/api/v1/assets/maintenance', }, + dispatch: { + base: '/api/v1/dispatch', + skills: '/api/v1/dispatch/skills', + shifts: '/api/v1/dispatch/shifts', + rules: '/api/v1/dispatch/rules', + }, }, documentation: '/api/v1/docs', }); diff --git a/src/modules/dispatch/controllers/dispatch.controller.ts b/src/modules/dispatch/controllers/dispatch.controller.ts new file mode 100644 index 0000000..5b32c95 --- /dev/null +++ b/src/modules/dispatch/controllers/dispatch.controller.ts @@ -0,0 +1,362 @@ +/** + * Dispatch Controller + * Mecanicas Diesel - ERP Suite + * + * REST API endpoints for dispatch operations. + * Module: MMD-011 Dispatch Center + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DispatchService } from '../services/dispatch.service'; +import { UnitStatusEnum, UnitCapacity } from '../entities/unit-status.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createDispatchController(dataSource: DataSource): Router { + const router = Router(); + const service = new DispatchService(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); + + // ========================================== + // Dispatch Board + // ========================================== + + /** + * Create dispatch board + * POST /api/dispatch/board + */ + router.post('/board', async (req: TenantRequest, res: Response) => { + try { + const board = await service.createBoard(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(board); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get active dispatch board + * GET /api/dispatch/board + */ + router.get('/board', async (req: TenantRequest, res: Response) => { + try { + const board = await service.getActiveBoard(req.tenantId!); + if (!board) { + return res.status(404).json({ error: 'No active dispatch board found' }); + } + res.json(board); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Unit Status + // ========================================== + + /** + * Create unit status + * POST /api/dispatch/units + */ + router.post('/units', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.createUnitStatus(req.tenantId!, req.body); + res.status(201).json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get all units + * GET /api/dispatch/units + */ + router.get('/units', async (req: TenantRequest, res: Response) => { + try { + const includeOffline = req.query.includeOffline !== 'false'; + const units = await service.getAllUnits(req.tenantId!, includeOffline); + res.json(units); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get available units + * GET /api/dispatch/units/available + */ + router.get('/units/available', async (req: TenantRequest, res: Response) => { + try { + const filters = { + unitCapacity: req.query.capacity as UnitCapacity, + canTow: req.query.canTow === 'true', + hasPosition: req.query.hasPosition === 'true', + }; + const units = await service.getAvailableUnits(req.tenantId!, filters); + res.json(units); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get dispatch stats + * GET /api/dispatch/stats + */ + router.get('/stats', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getDispatchStats(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get unit status by unit ID + * GET /api/dispatch/units/:unitId + */ + router.get('/units/:unitId', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.getUnitStatus(req.tenantId!, req.params.unitId); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit status not found' }); + } + res.json(unitStatus); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update unit status + * PATCH /api/dispatch/units/:unitId + */ + router.patch('/units/:unitId', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.updateUnitStatus( + req.tenantId!, + req.params.unitId, + req.body + ); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit status not found' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Update unit position (from GPS) + * PATCH /api/dispatch/units/:unitId/position + */ + router.patch('/units/:unitId/position', async (req: TenantRequest, res: Response) => { + try { + const { latitude, longitude, positionId } = req.body; + const unitStatus = await service.updateUnitStatus(req.tenantId!, req.params.unitId, { + lastKnownLat: latitude, + lastKnownLng: longitude, + lastPositionId: positionId, + }); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit status not found' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Assignment Operations + // ========================================== + + /** + * Assign incident to unit + * POST /api/dispatch/assign + */ + router.post('/assign', async (req: TenantRequest, res: Response) => { + try { + const { incidentId, unitId, technicianIds, notes } = req.body; + const unitStatus = await service.assignIncident( + req.tenantId!, + incidentId, + { unitId, technicianIds, notes }, + req.userId! + ); + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Reassign incident to different unit + * POST /api/dispatch/reassign + */ + router.post('/reassign', async (req: TenantRequest, res: Response) => { + try { + const { incidentId, unitId, technicianIds, reason } = req.body; + const unitStatus = await service.reassignIncident( + req.tenantId!, + incidentId, + { unitId, technicianIds }, + reason, + req.userId! + ); + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Suggest best assignment for incident + * POST /api/dispatch/suggest + */ + router.post('/suggest', async (req: TenantRequest, res: Response) => { + try { + const { incidentId, latitude, longitude, requiredSkills, requiredCapacity } = req.body; + const suggestions = await service.suggestBestAssignment( + req.tenantId!, + incidentId, + latitude, + longitude, + requiredSkills, + requiredCapacity + ); + res.json(suggestions); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark unit as en route + * POST /api/dispatch/units/:unitId/en-route + */ + router.post('/units/:unitId/en-route', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.updateUnitToEnRoute( + req.tenantId!, + req.params.unitId, + req.userId! + ); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit not found or no active incident' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark unit as on site + * POST /api/dispatch/units/:unitId/on-site + */ + router.post('/units/:unitId/on-site', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.updateUnitToOnSite( + req.tenantId!, + req.params.unitId, + req.userId! + ); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit not found or no active incident' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Complete incident and mark unit as returning + * POST /api/dispatch/units/:unitId/complete + */ + router.post('/units/:unitId/complete', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.completeIncident( + req.tenantId!, + req.params.unitId, + req.userId! + ); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit not found or no active incident' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Release unit (make available) + * POST /api/dispatch/units/:unitId/release + */ + router.post('/units/:unitId/release', async (req: TenantRequest, res: Response) => { + try { + const unitStatus = await service.releaseUnit(req.tenantId!, req.params.unitId); + if (!unitStatus) { + return res.status(404).json({ error: 'Unit not found' }); + } + res.json(unitStatus); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Dispatch Logs + // ========================================== + + /** + * Get dispatch logs for incident + * GET /api/dispatch/logs/incident/:incidentId + */ + router.get('/logs/incident/:incidentId', async (req: TenantRequest, res: Response) => { + try { + const logs = await service.getDispatchLogs(req.tenantId!, req.params.incidentId); + res.json(logs); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get recent dispatch logs + * GET /api/dispatch/logs + */ + router.get('/logs', async (req: TenantRequest, res: Response) => { + try { + const limit = parseInt(req.query.limit as string, 10) || 50; + const logs = await service.getRecentLogs(req.tenantId!, Math.min(limit, 200)); + res.json(logs); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/index.ts b/src/modules/dispatch/controllers/index.ts new file mode 100644 index 0000000..f3afaeb --- /dev/null +++ b/src/modules/dispatch/controllers/index.ts @@ -0,0 +1,11 @@ +/** + * Dispatch Module Controllers Index + * Mecanicas Diesel - ERP Suite + * + * Module: MMD-011 Dispatch Center + */ + +export { createDispatchController } from './dispatch.controller'; +export { createSkillController } from './skill.controller'; +export { createShiftController } from './shift.controller'; +export { createRuleController } from './rule.controller'; diff --git a/src/modules/dispatch/controllers/rule.controller.ts b/src/modules/dispatch/controllers/rule.controller.ts new file mode 100644 index 0000000..d6cefcf --- /dev/null +++ b/src/modules/dispatch/controllers/rule.controller.ts @@ -0,0 +1,259 @@ +/** + * Rule Controller + * Mecanicas Diesel - ERP Suite + * + * REST API endpoints for dispatch and escalation rules. + * Module: MMD-011 Dispatch Center + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { RuleService } from '../services/rule.service'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createRuleController(dataSource: DataSource): Router { + const router = Router(); + const service = new RuleService(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); + + // ========================================== + // Dispatch Rules + // ========================================== + + /** + * Create dispatch rule + * POST /api/dispatch/rules/dispatch + */ + router.post('/dispatch', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.createDispatchRule(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(rule); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List dispatch rules + * GET /api/dispatch/rules/dispatch + */ + router.get('/dispatch', async (req: TenantRequest, res: Response) => { + try { + const activeOnly = req.query.activeOnly === 'true'; + 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.listDispatchRules(req.tenantId!, activeOnly, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get matching dispatch rules for incident + * POST /api/dispatch/rules/dispatch/match + */ + router.post('/dispatch/match', async (req: TenantRequest, res: Response) => { + try { + const { serviceTypeCode, incidentCategory } = req.body; + const rules = await service.getMatchingDispatchRules( + req.tenantId!, + serviceTypeCode, + incidentCategory + ); + res.json(rules); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Evaluate rules for assignment + * POST /api/dispatch/rules/dispatch/evaluate + */ + router.post('/dispatch/evaluate', async (req: TenantRequest, res: Response) => { + try { + const matches = await service.evaluateRules(req.tenantId!, req.body); + res.json(matches); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get dispatch rule by ID + * GET /api/dispatch/rules/dispatch/:id + */ + router.get('/dispatch/:id', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.getDispatchRuleById(req.tenantId!, req.params.id); + if (!rule) { + return res.status(404).json({ error: 'Dispatch rule not found' }); + } + res.json(rule); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update dispatch rule + * PATCH /api/dispatch/rules/dispatch/:id + */ + router.patch('/dispatch/:id', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.updateDispatchRule(req.tenantId!, req.params.id, req.body); + if (!rule) { + return res.status(404).json({ error: 'Dispatch rule not found' }); + } + res.json(rule); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete dispatch rule + * DELETE /api/dispatch/rules/dispatch/:id + */ + router.delete('/dispatch/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deleteDispatchRule(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Dispatch rule not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Escalation Rules + // ========================================== + + /** + * Create escalation rule + * POST /api/dispatch/rules/escalation + */ + router.post('/escalation', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.createEscalationRule(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(rule); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List escalation rules + * GET /api/dispatch/rules/escalation + */ + router.get('/escalation', async (req: TenantRequest, res: Response) => { + try { + const activeOnly = req.query.activeOnly === 'true'; + 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.listEscalationRules(req.tenantId!, activeOnly, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get triggered escalation rules + * POST /api/dispatch/rules/escalation/triggered + */ + router.post('/escalation/triggered', async (req: TenantRequest, res: Response) => { + try { + const { elapsedMinutes, incidentStatus, incidentPriority } = req.body; + const rules = await service.getTriggeredEscalationRules( + req.tenantId!, + elapsedMinutes, + incidentStatus, + incidentPriority + ); + res.json(rules); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get escalation rule by ID + * GET /api/dispatch/rules/escalation/:id + */ + router.get('/escalation/:id', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.getEscalationRuleById(req.tenantId!, req.params.id); + if (!rule) { + return res.status(404).json({ error: 'Escalation rule not found' }); + } + res.json(rule); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update escalation rule + * PATCH /api/dispatch/rules/escalation/:id + */ + router.patch('/escalation/:id', async (req: TenantRequest, res: Response) => { + try { + const rule = await service.updateEscalationRule(req.tenantId!, req.params.id, req.body); + if (!rule) { + return res.status(404).json({ error: 'Escalation rule not found' }); + } + res.json(rule); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete escalation rule + * DELETE /api/dispatch/rules/escalation/:id + */ + router.delete('/escalation/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deleteEscalationRule(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Escalation rule not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/shift.controller.ts b/src/modules/dispatch/controllers/shift.controller.ts new file mode 100644 index 0000000..85f617c --- /dev/null +++ b/src/modules/dispatch/controllers/shift.controller.ts @@ -0,0 +1,304 @@ +/** + * Shift Controller + * Mecanicas Diesel - ERP Suite + * + * REST API endpoints for technician shifts. + * Module: MMD-011 Dispatch Center + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ShiftService, ShiftFilters } from '../services/shift.service'; +import { ShiftType } from '../entities/technician-shift.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createShiftController(dataSource: DataSource): Router { + const router = Router(); + const service = new ShiftService(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); + + /** + * Create shift + * POST /api/dispatch/shifts + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const shift = await service.createShift(req.tenantId!, { + ...req.body, + shiftDate: new Date(req.body.shiftDate), + createdBy: req.userId, + }); + res.status(201).json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List shifts with filters + * GET /api/dispatch/shifts + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: ShiftFilters = { + technicianId: req.query.technicianId as string, + shiftType: req.query.shiftType as ShiftType, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + isOnCall: req.query.isOnCall === 'true' ? true : req.query.isOnCall === 'false' ? false : undefined, + assignedUnitId: req.query.assignedUnitId as string, + isAbsent: req.query.isAbsent === 'true' ? true : req.query.isAbsent === 'false' ? false : 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 shifts for date + * GET /api/dispatch/shifts/date/:date + */ + router.get('/date/:date', async (req: TenantRequest, res: Response) => { + try { + const date = new Date(req.params.date); + const excludeAbsent = req.query.excludeAbsent !== 'false'; + const shifts = await service.getShiftsForDate(req.tenantId!, date, excludeAbsent); + res.json(shifts); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get available technicians for date/time + * GET /api/dispatch/shifts/available + */ + router.get('/available', async (req: TenantRequest, res: Response) => { + try { + const date = new Date(req.query.date as string); + const time = req.query.time as string; + const availability = await service.getAvailableTechnicians(req.tenantId!, date, time); + res.json(availability); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get on-call technicians for date + * GET /api/dispatch/shifts/on-call + */ + router.get('/on-call', async (req: TenantRequest, res: Response) => { + try { + const date = new Date(req.query.date as string); + const shifts = await service.getOnCallTechnicians(req.tenantId!, date); + res.json(shifts); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get shifts for technician + * GET /api/dispatch/shifts/technician/:technicianId + */ + router.get('/technician/:technicianId', async (req: TenantRequest, res: Response) => { + try { + const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined; + const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : undefined; + const shifts = await service.getTechnicianShifts( + req.tenantId!, + req.params.technicianId, + dateFrom, + dateTo + ); + res.json(shifts); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get shifts by unit + * GET /api/dispatch/shifts/unit/:unitId + */ + router.get('/unit/:unitId', async (req: TenantRequest, res: Response) => { + try { + const date = new Date(req.query.date as string); + const shifts = await service.getShiftsByUnit(req.tenantId!, req.params.unitId, date); + res.json(shifts); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Generate weekly shifts + * POST /api/dispatch/shifts/generate-weekly + */ + router.post('/generate-weekly', async (req: TenantRequest, res: Response) => { + try { + const { + technicianId, + weekStartDate, + shiftType, + startTime, + endTime, + daysOfWeek, + } = req.body; + + const shifts = await service.generateWeeklyShifts( + req.tenantId!, + technicianId, + new Date(weekStartDate), + shiftType, + startTime, + endTime, + daysOfWeek, + req.userId + ); + res.status(201).json(shifts); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get shift by ID + * GET /api/dispatch/shifts/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const shift = await service.getById(req.tenantId!, req.params.id); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update shift + * PATCH /api/dispatch/shifts/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const shift = await service.updateShift(req.tenantId!, req.params.id, req.body); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Start shift + * POST /api/dispatch/shifts/:id/start + */ + router.post('/:id/start', async (req: TenantRequest, res: Response) => { + try { + const shift = await service.startShift(req.tenantId!, req.params.id); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * End shift + * POST /api/dispatch/shifts/:id/end + */ + router.post('/:id/end', async (req: TenantRequest, res: Response) => { + try { + const shift = await service.endShift(req.tenantId!, req.params.id); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark absent + * POST /api/dispatch/shifts/:id/absent + */ + router.post('/:id/absent', async (req: TenantRequest, res: Response) => { + try { + const { reason } = req.body; + const shift = await service.markAbsent(req.tenantId!, req.params.id, reason); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Assign unit to shift + * POST /api/dispatch/shifts/:id/assign-unit + */ + router.post('/:id/assign-unit', async (req: TenantRequest, res: Response) => { + try { + const { unitId } = req.body; + const shift = await service.assignUnitToShift(req.tenantId!, req.params.id, unitId); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.json(shift); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete shift + * DELETE /api/dispatch/shifts/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deleteShift(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Shift not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/skill.controller.ts b/src/modules/dispatch/controllers/skill.controller.ts new file mode 100644 index 0000000..ed02473 --- /dev/null +++ b/src/modules/dispatch/controllers/skill.controller.ts @@ -0,0 +1,223 @@ +/** + * Skill Controller + * Mecanicas Diesel - ERP Suite + * + * REST API endpoints for technician skills. + * Module: MMD-011 Dispatch Center + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SkillService, SkillFilters } from '../services/skill.service'; +import { SkillLevel } from '../entities/technician-skill.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createSkillController(dataSource: DataSource): Router { + const router = Router(); + const service = new SkillService(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); + + /** + * Add skill to technician + * POST /api/dispatch/skills + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const skill = await service.addSkill(req.tenantId!, req.body); + res.status(201).json(skill); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List skills with filters + * GET /api/dispatch/skills + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: SkillFilters = { + technicianId: req.query.technicianId as string, + skillCode: req.query.skillCode as string, + level: req.query.level as SkillLevel, + isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined, + expiringWithinDays: req.query.expiringWithinDays + ? parseInt(req.query.expiringWithinDays as string, 10) + : 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 skill matrix + * GET /api/dispatch/skills/matrix + */ + router.get('/matrix', async (req: TenantRequest, res: Response) => { + try { + const matrix = await service.getSkillMatrix(req.tenantId!); + res.json(matrix); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get expiring skills + * GET /api/dispatch/skills/expiring + */ + router.get('/expiring', async (req: TenantRequest, res: Response) => { + try { + const withinDays = parseInt(req.query.days as string, 10) || 30; + const skills = await service.getExpiringSkills(req.tenantId!, withinDays); + res.json(skills); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get technicians with specific skill + * GET /api/dispatch/skills/by-code/:skillCode + */ + router.get('/by-code/:skillCode', async (req: TenantRequest, res: Response) => { + try { + const minLevel = req.query.minLevel as SkillLevel; + const skills = await service.getTechniciansWithSkill( + req.tenantId!, + req.params.skillCode, + minLevel + ); + res.json(skills); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get skills for technician + * GET /api/dispatch/skills/technician/:technicianId + */ + router.get('/technician/:technicianId', async (req: TenantRequest, res: Response) => { + try { + const activeOnly = req.query.activeOnly !== 'false'; + const skills = await service.getTechnicianSkills( + req.tenantId!, + req.params.technicianId, + activeOnly + ); + res.json(skills); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Validate technician skills for service + * POST /api/dispatch/skills/validate + */ + router.post('/validate', async (req: TenantRequest, res: Response) => { + try { + const { technicianId, requiredSkills, minLevel } = req.body; + const result = await service.validateTechnicianSkills( + req.tenantId!, + technicianId, + requiredSkills, + minLevel + ); + res.json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get skill by ID + * GET /api/dispatch/skills/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const skill = await service.getById(req.tenantId!, req.params.id); + if (!skill) { + return res.status(404).json({ error: 'Skill not found' }); + } + res.json(skill); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update skill + * PATCH /api/dispatch/skills/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const skill = await service.updateSkill(req.tenantId!, req.params.id, req.body); + if (!skill) { + return res.status(404).json({ error: 'Skill not found' }); + } + res.json(skill); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Verify skill (admin) + * POST /api/dispatch/skills/:id/verify + */ + router.post('/:id/verify', async (req: TenantRequest, res: Response) => { + try { + const skill = await service.verifySkill(req.tenantId!, req.params.id, req.userId!); + if (!skill) { + return res.status(404).json({ error: 'Skill not found' }); + } + res.json(skill); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Deactivate skill + * DELETE /api/dispatch/skills/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deactivateSkill(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Skill not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/entities/dispatch-board.entity.ts b/src/modules/dispatch/entities/dispatch-board.entity.ts new file mode 100644 index 0000000..17c6dc1 --- /dev/null +++ b/src/modules/dispatch/entities/dispatch-board.entity.ts @@ -0,0 +1,71 @@ +/** + * DispatchBoard Entity + * Mecanicas Diesel - ERP Suite + * + * Dispatch board configuration for map and assignment views. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'dispatch_boards', schema: 'dispatch' }) +@Index('idx_dispatch_boards_tenant', ['tenantId']) +export class DispatchBoard { + @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; + + // Map defaults + @Column({ name: 'default_zoom', type: 'integer', default: 12 }) + defaultZoom: number; + + @Column({ name: 'center_lat', type: 'decimal', precision: 10, scale: 7, default: 19.4326 }) + centerLat: number; + + @Column({ name: 'center_lng', type: 'decimal', precision: 10, scale: 7, default: -99.1332 }) + centerLng: number; + + // Behavior + @Column({ name: 'refresh_interval_seconds', type: 'integer', default: 30 }) + refreshIntervalSeconds: number; + + @Column({ name: 'show_offline_units', type: 'boolean', default: true }) + showOfflineUnits: boolean; + + @Column({ name: 'auto_assign_enabled', type: 'boolean', default: false }) + autoAssignEnabled: boolean; + + @Column({ name: 'max_suggestions', type: 'integer', default: 5 }) + maxSuggestions: number; + + // Filters + @Column({ name: 'default_filters', type: 'jsonb', default: {} }) + defaultFilters: Record; + + @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; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/dispatch-log.entity.ts b/src/modules/dispatch/entities/dispatch-log.entity.ts new file mode 100644 index 0000000..bef6676 --- /dev/null +++ b/src/modules/dispatch/entities/dispatch-log.entity.ts @@ -0,0 +1,90 @@ +/** + * DispatchLog Entity + * Mecanicas Diesel - ERP Suite + * + * Audit log of all dispatch actions. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export enum DispatchAction { + CREATED = 'created', + ASSIGNED = 'assigned', + REASSIGNED = 'reassigned', + REJECTED = 'rejected', + ESCALATED = 'escalated', + CANCELLED = 'cancelled', + ACKNOWLEDGED = 'acknowledged', + COMPLETED = 'completed', +} + +@Entity({ name: 'dispatch_logs', schema: 'dispatch' }) +@Index('idx_dispatch_logs_tenant', ['tenantId']) +@Index('idx_dispatch_logs_incident', ['tenantId', 'incidentId']) +@Index('idx_dispatch_logs_time', ['tenantId', 'performedAt']) +@Index('idx_dispatch_logs_action', ['tenantId', 'action']) +export class DispatchLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Incident reference + @Column({ name: 'incident_id', type: 'uuid' }) + incidentId: string; + + // Action + @Column({ + type: 'varchar', + length: 20, + }) + action: DispatchAction; + + // Unit/Technician changes + @Column({ name: 'from_unit_id', type: 'uuid', nullable: true }) + fromUnitId?: string; + + @Column({ name: 'to_unit_id', type: 'uuid', nullable: true }) + toUnitId?: string; + + @Column({ name: 'from_technician_id', type: 'uuid', nullable: true }) + fromTechnicianId?: string; + + @Column({ name: 'to_technician_id', type: 'uuid', nullable: true }) + toTechnicianId?: string; + + // Context + @Column({ type: 'text', nullable: true }) + reason?: string; + + @Column({ type: 'boolean', default: false }) + automated: boolean; + + @Column({ name: 'rule_id', type: 'uuid', nullable: true }) + ruleId?: string; + + @Column({ name: 'escalation_id', type: 'uuid', nullable: true }) + escalationId?: string; + + // Response times + @Column({ name: 'response_time_seconds', type: 'integer', nullable: true }) + responseTimeSeconds?: number; + + // Actor + @Column({ name: 'performed_by', type: 'uuid', nullable: true }) + performedBy?: string; + + @Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' }) + performedAt: Date; + + // Extra data + @Column({ type: 'jsonb', default: {} }) + metadata: Record; +} diff --git a/src/modules/dispatch/entities/dispatch-rule.entity.ts b/src/modules/dispatch/entities/dispatch-rule.entity.ts new file mode 100644 index 0000000..8cb54fc --- /dev/null +++ b/src/modules/dispatch/entities/dispatch-rule.entity.ts @@ -0,0 +1,76 @@ +/** + * DispatchRule Entity + * Mecanicas Diesel - ERP Suite + * + * Rules for automatic assignment suggestions. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export interface DispatchConditions { + requiresSkills?: string[]; + requiresAssetCategories?: string[]; + minUnitCapacity?: 'light' | 'medium' | 'heavy'; + maxDistanceKm?: number; + zoneCodes?: string[]; + minSkillLevel?: 'basic' | 'intermediate' | 'advanced' | 'expert'; + excludeOnCall?: boolean; +} + +@Entity({ name: 'dispatch_rules', schema: 'dispatch' }) +@Index('idx_dispatch_rules_tenant', ['tenantId']) +@Index('idx_dispatch_rules_priority', ['tenantId', 'priority']) +export class DispatchRule { + @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({ type: 'integer', default: 0 }) + priority: number; + + // Applicability + @Column({ name: 'service_type_code', type: 'varchar', length: 50, nullable: true }) + serviceTypeCode?: string; + + @Column({ name: 'incident_category', type: 'varchar', length: 50, nullable: true }) + incidentCategory?: string; + + // Conditions + @Column({ type: 'jsonb', default: {} }) + conditions: DispatchConditions; + + // Action + @Column({ name: 'auto_assign', type: 'boolean', default: false }) + autoAssign: boolean; + + @Column({ name: 'assignment_weight', type: 'integer', default: 100 }) + assignmentWeight: 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; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/escalation-rule.entity.ts b/src/modules/dispatch/entities/escalation-rule.entity.ts new file mode 100644 index 0000000..e57b9d3 --- /dev/null +++ b/src/modules/dispatch/entities/escalation-rule.entity.ts @@ -0,0 +1,91 @@ +/** + * EscalationRule Entity + * Mecanicas Diesel - ERP Suite + * + * Rules for escalating unresponded incidents. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum NotificationChannel { + EMAIL = 'email', + SMS = 'sms', + WHATSAPP = 'whatsapp', + PUSH = 'push', + CALL = 'call', +} + +@Entity({ name: 'escalation_rules', schema: 'dispatch' }) +@Index('idx_escalation_rules_tenant', ['tenantId']) +@Index('idx_escalation_rules_trigger', ['tenantId', 'triggerAfterMinutes']) +export class EscalationRule { + @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; + + // Trigger conditions + @Column({ name: 'trigger_after_minutes', type: 'integer' }) + triggerAfterMinutes: number; + + @Column({ name: 'trigger_status', type: 'varchar', length: 50, nullable: true }) + triggerStatus?: string; + + @Column({ name: 'trigger_priority', type: 'varchar', length: 20, nullable: true }) + triggerPriority?: string; + + // Escalation target + @Column({ name: 'escalate_to_role', type: 'varchar', length: 50 }) + escalateToRole: string; + + @Column({ name: 'escalate_to_user_ids', type: 'uuid', array: true, nullable: true }) + escalateToUserIds?: string[]; + + // Notification + @Column({ + name: 'notification_channel', + type: 'varchar', + length: 20, + }) + notificationChannel: NotificationChannel; + + @Column({ name: 'notification_template', type: 'text', nullable: true }) + notificationTemplate?: string; + + @Column({ name: 'notification_data', type: 'jsonb', default: {} }) + notificationData: Record; + + // Repeat + @Column({ name: 'repeat_interval_minutes', type: 'integer', nullable: true }) + repeatIntervalMinutes?: number; + + @Column({ name: 'max_escalations', type: 'integer', default: 3 }) + maxEscalations: 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; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/index.ts b/src/modules/dispatch/entities/index.ts new file mode 100644 index 0000000..d68c5ee --- /dev/null +++ b/src/modules/dispatch/entities/index.ts @@ -0,0 +1,14 @@ +/** + * Dispatch Module Entities Index + * Mecanicas Diesel - ERP Suite + * + * Module: MMD-011 Dispatch Center + */ + +export { DispatchBoard } from './dispatch-board.entity'; +export { UnitStatus, UnitStatusEnum, UnitCapacity } from './unit-status.entity'; +export { TechnicianSkill, SkillLevel } from './technician-skill.entity'; +export { TechnicianShift, ShiftType } from './technician-shift.entity'; +export { DispatchRule, DispatchConditions } from './dispatch-rule.entity'; +export { EscalationRule, NotificationChannel } from './escalation-rule.entity'; +export { DispatchLog, DispatchAction } from './dispatch-log.entity'; diff --git a/src/modules/dispatch/entities/technician-shift.entity.ts b/src/modules/dispatch/entities/technician-shift.entity.ts new file mode 100644 index 0000000..c2c67f6 --- /dev/null +++ b/src/modules/dispatch/entities/technician-shift.entity.ts @@ -0,0 +1,93 @@ +/** + * TechnicianShift Entity + * Mecanicas Diesel - ERP Suite + * + * Shift schedules for technicians. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ShiftType { + MORNING = 'morning', + AFTERNOON = 'afternoon', + NIGHT = 'night', + FULL_DAY = 'full_day', + ON_CALL = 'on_call', +} + +@Entity({ name: 'technician_shifts', schema: 'dispatch' }) +@Index('idx_technician_shifts_tenant', ['tenantId']) +@Index('idx_technician_shifts_technician_date', ['tenantId', 'technicianId', 'shiftDate']) +@Index('idx_technician_shifts_date', ['tenantId', 'shiftDate']) +export class TechnicianShift { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Technician reference + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + // Schedule + @Column({ name: 'shift_date', type: 'date' }) + shiftDate: Date; + + @Column({ + name: 'shift_type', + type: 'varchar', + length: 20, + }) + shiftType: ShiftType; + + @Column({ name: 'start_time', type: 'time' }) + startTime: string; + + @Column({ name: 'end_time', type: 'time' }) + endTime: string; + + // On-call specifics + @Column({ name: 'is_on_call', type: 'boolean', default: false }) + isOnCall: boolean; + + @Column({ name: 'on_call_priority', type: 'integer', default: 0 }) + onCallPriority: number; + + // Assignment + @Column({ name: 'assigned_unit_id', type: 'uuid', nullable: true }) + assignedUnitId?: string; + + // Status + @Column({ name: 'actual_start_time', type: 'timestamptz', nullable: true }) + actualStartTime?: Date; + + @Column({ name: 'actual_end_time', type: 'timestamptz', nullable: true }) + actualEndTime?: Date; + + @Column({ name: 'is_absent', type: 'boolean', default: false }) + isAbsent: boolean; + + @Column({ name: 'absence_reason', type: 'text', nullable: true }) + absenceReason?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @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; +} diff --git a/src/modules/dispatch/entities/technician-skill.entity.ts b/src/modules/dispatch/entities/technician-skill.entity.ts new file mode 100644 index 0000000..4a1ab46 --- /dev/null +++ b/src/modules/dispatch/entities/technician-skill.entity.ts @@ -0,0 +1,85 @@ +/** + * TechnicianSkill Entity + * Mecanicas Diesel - ERP Suite + * + * Skills and certifications of technicians. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum SkillLevel { + BASIC = 'basic', + INTERMEDIATE = 'intermediate', + ADVANCED = 'advanced', + EXPERT = 'expert', +} + +@Entity({ name: 'technician_skills', schema: 'dispatch' }) +@Index('idx_technician_skills_tenant', ['tenantId']) +@Index('idx_technician_skills_technician', ['tenantId', 'technicianId']) +@Index('idx_technician_skills_code', ['tenantId', 'skillCode']) +export class TechnicianSkill { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Technician reference + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + // Skill definition + @Column({ name: 'skill_code', type: 'varchar', length: 50 }) + skillCode: string; + + @Column({ name: 'skill_name', type: 'varchar', length: 100 }) + skillName: string; + + @Column({ name: 'skill_description', type: 'text', nullable: true }) + skillDescription?: string; + + @Column({ + type: 'varchar', + length: 20, + default: SkillLevel.BASIC, + }) + level: SkillLevel; + + // Certification + @Column({ name: 'certification_number', type: 'varchar', length: 100, nullable: true }) + certificationNumber?: string; + + @Column({ name: 'certified_at', type: 'date', nullable: true }) + certifiedAt?: Date; + + @Column({ name: 'expires_at', type: 'date', nullable: true }) + expiresAt?: Date; + + @Column({ name: 'certification_document_url', type: 'varchar', length: 500, nullable: true }) + certificationDocumentUrl?: string; + + // Status + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy?: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/dispatch/entities/unit-status.entity.ts b/src/modules/dispatch/entities/unit-status.entity.ts new file mode 100644 index 0000000..5b9138e --- /dev/null +++ b/src/modules/dispatch/entities/unit-status.entity.ts @@ -0,0 +1,111 @@ +/** + * UnitStatus Entity + * Mecanicas Diesel - ERP Suite + * + * Real-time status of fleet units for dispatch. + * Module: MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum UnitStatusEnum { + AVAILABLE = 'available', + ASSIGNED = 'assigned', + EN_ROUTE = 'en_route', + ON_SITE = 'on_site', + RETURNING = 'returning', + OFFLINE = 'offline', + MAINTENANCE = 'maintenance', +} + +export enum UnitCapacity { + LIGHT = 'light', + MEDIUM = 'medium', + HEAVY = 'heavy', +} + +@Entity({ name: 'unit_statuses', schema: 'dispatch' }) +@Index('idx_unit_statuses_tenant_unit', ['tenantId', 'unitId'], { unique: true }) +@Index('idx_unit_statuses_status', ['tenantId', 'status']) +export class UnitStatus { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Unit reference + @Column({ name: 'unit_id', type: 'uuid' }) + unitId: string; + + @Column({ name: 'unit_code', type: 'varchar', length: 50, nullable: true }) + unitCode?: string; + + @Column({ name: 'unit_name', type: 'varchar', length: 100, nullable: true }) + unitName?: string; + + // Status + @Column({ + type: 'varchar', + length: 20, + default: UnitStatusEnum.OFFLINE, + }) + status: UnitStatusEnum; + + // Current assignment + @Column({ name: 'current_incident_id', type: 'uuid', nullable: true }) + currentIncidentId?: string; + + @Column({ name: 'current_technician_ids', type: 'uuid', array: true, default: [] }) + currentTechnicianIds: string[]; + + // Location (cached from GPS) + @Column({ name: 'last_position_id', type: 'uuid', nullable: true }) + lastPositionId?: string; + + @Column({ name: 'last_known_lat', type: 'decimal', precision: 10, scale: 7, nullable: true }) + lastKnownLat?: number; + + @Column({ name: 'last_known_lng', type: 'decimal', precision: 10, scale: 7, nullable: true }) + lastKnownLng?: number; + + @Column({ name: 'last_location_update', type: 'timestamptz', nullable: true }) + lastLocationUpdate?: Date; + + // Timing + @Column({ name: 'last_status_change', type: 'timestamptz', default: () => 'NOW()' }) + lastStatusChange: Date; + + @Column({ name: 'estimated_available_at', type: 'timestamptz', nullable: true }) + estimatedAvailableAt?: Date; + + // Capacity and capabilities + @Column({ + name: 'unit_capacity', + type: 'varchar', + length: 20, + default: UnitCapacity.LIGHT, + }) + unitCapacity: UnitCapacity; + + @Column({ name: 'can_tow', type: 'boolean', default: false }) + canTow: boolean; + + @Column({ name: 'max_tow_weight_kg', type: 'integer', nullable: true }) + maxTowWeightKg?: number; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/dispatch/index.ts b/src/modules/dispatch/index.ts new file mode 100644 index 0000000..29819a5 --- /dev/null +++ b/src/modules/dispatch/index.ts @@ -0,0 +1,15 @@ +/** + * Dispatch Module Index + * Mecanicas Diesel - ERP Suite + * + * Module: MMD-011 Dispatch Center + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/dispatch/services/dispatch.service.ts b/src/modules/dispatch/services/dispatch.service.ts new file mode 100644 index 0000000..f293f14 --- /dev/null +++ b/src/modules/dispatch/services/dispatch.service.ts @@ -0,0 +1,540 @@ +/** + * Dispatch Service + * Mecanicas Diesel - ERP Suite + * + * Business logic for dispatch operations. + * Module: MMD-011 Dispatch Center + */ + +import { Repository, DataSource } from 'typeorm'; +import { UnitStatus, UnitStatusEnum, UnitCapacity } from '../entities/unit-status.entity'; +import { DispatchLog, DispatchAction } from '../entities/dispatch-log.entity'; +import { DispatchBoard } from '../entities/dispatch-board.entity'; + +// DTOs +export interface CreateUnitStatusDto { + unitId: string; + unitCode?: string; + unitName?: string; + unitCapacity?: UnitCapacity; + canTow?: boolean; + maxTowWeightKg?: number; +} + +export interface UpdateUnitStatusDto { + status?: UnitStatusEnum; + currentIncidentId?: string | null; + currentTechnicianIds?: string[]; + lastKnownLat?: number; + lastKnownLng?: number; + lastPositionId?: string; + estimatedAvailableAt?: Date; + notes?: string; + metadata?: Record; +} + +export interface AssignmentDto { + unitId: string; + technicianIds?: string[]; + notes?: string; +} + +export interface AssignmentSuggestion { + unitId: string; + unitCode?: string; + unitName?: string; + score: number; + distanceKm?: number; + technicianIds?: string[]; + reasons: string[]; +} + +export interface UnitStatusFilters { + status?: UnitStatusEnum; + unitCapacity?: UnitCapacity; + canTow?: boolean; + hasPosition?: boolean; +} + +export interface CreateDispatchBoardDto { + name: string; + description?: string; + defaultZoom?: number; + centerLat?: number; + centerLng?: number; + refreshIntervalSeconds?: number; + showOfflineUnits?: boolean; + autoAssignEnabled?: boolean; + createdBy?: string; +} + +export class DispatchService { + private unitStatusRepository: Repository; + private dispatchLogRepository: Repository; + private dispatchBoardRepository: Repository; + + constructor(dataSource: DataSource) { + this.unitStatusRepository = dataSource.getRepository(UnitStatus); + this.dispatchLogRepository = dataSource.getRepository(DispatchLog); + this.dispatchBoardRepository = dataSource.getRepository(DispatchBoard); + } + + // ========================================== + // Dispatch Board Management + // ========================================== + + async createBoard(tenantId: string, dto: CreateDispatchBoardDto): Promise { + const board = this.dispatchBoardRepository.create({ + tenantId, + ...dto, + }); + return this.dispatchBoardRepository.save(board); + } + + async getBoard(tenantId: string, id: string): Promise { + return this.dispatchBoardRepository.findOne({ + where: { id, tenantId }, + }); + } + + async getActiveBoard(tenantId: string): Promise { + return this.dispatchBoardRepository.findOne({ + where: { tenantId, isActive: true }, + order: { createdAt: 'DESC' }, + }); + } + + // ========================================== + // Unit Status Management + // ========================================== + + async createUnitStatus(tenantId: string, dto: CreateUnitStatusDto): Promise { + const existing = await this.unitStatusRepository.findOne({ + where: { tenantId, unitId: dto.unitId }, + }); + + if (existing) { + throw new Error(`Unit status for unit ${dto.unitId} already exists`); + } + + const unitStatus = this.unitStatusRepository.create({ + tenantId, + ...dto, + status: UnitStatusEnum.OFFLINE, + }); + + return this.unitStatusRepository.save(unitStatus); + } + + async getUnitStatus(tenantId: string, unitId: string): Promise { + return this.unitStatusRepository.findOne({ + where: { tenantId, unitId }, + }); + } + + async updateUnitStatus( + tenantId: string, + unitId: string, + dto: UpdateUnitStatusDto + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, unitId); + if (!unitStatus) return null; + + if (dto.lastKnownLat !== undefined && dto.lastKnownLng !== undefined) { + unitStatus.lastLocationUpdate = new Date(); + } + + Object.assign(unitStatus, dto); + return this.unitStatusRepository.save(unitStatus); + } + + async getAvailableUnits( + tenantId: string, + filters: UnitStatusFilters = {} + ): Promise { + const qb = this.unitStatusRepository + .createQueryBuilder('us') + .where('us.tenant_id = :tenantId', { tenantId }) + .andWhere('us.status = :status', { status: UnitStatusEnum.AVAILABLE }); + + if (filters.unitCapacity) { + qb.andWhere('us.unit_capacity = :capacity', { capacity: filters.unitCapacity }); + } + if (filters.canTow !== undefined) { + qb.andWhere('us.can_tow = :canTow', { canTow: filters.canTow }); + } + if (filters.hasPosition) { + qb.andWhere('us.last_known_lat IS NOT NULL AND us.last_known_lng IS NOT NULL'); + } + + return qb.orderBy('us.last_status_change', 'ASC').getMany(); + } + + async getAllUnits( + tenantId: string, + includeOffline: boolean = true + ): Promise { + const qb = this.unitStatusRepository + .createQueryBuilder('us') + .where('us.tenant_id = :tenantId', { tenantId }); + + if (!includeOffline) { + qb.andWhere('us.status != :offline', { offline: UnitStatusEnum.OFFLINE }); + } + + return qb.orderBy('us.status', 'ASC').addOrderBy('us.unit_code', 'ASC').getMany(); + } + + // ========================================== + // Assignment Operations + // ========================================== + + async assignIncident( + tenantId: string, + incidentId: string, + assignment: AssignmentDto, + performedBy: string + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, assignment.unitId); + if (!unitStatus) { + throw new Error(`Unit ${assignment.unitId} not found`); + } + + if (unitStatus.status !== UnitStatusEnum.AVAILABLE) { + throw new Error(`Unit ${assignment.unitId} is not available (status: ${unitStatus.status})`); + } + + // Update unit status + unitStatus.status = UnitStatusEnum.ASSIGNED; + unitStatus.currentIncidentId = incidentId; + unitStatus.currentTechnicianIds = assignment.technicianIds || []; + unitStatus.notes = assignment.notes; + await this.unitStatusRepository.save(unitStatus); + + // Log the assignment + await this.logDispatchAction(tenantId, { + incidentId, + action: DispatchAction.ASSIGNED, + toUnitId: assignment.unitId, + toTechnicianId: assignment.technicianIds?.[0], + performedBy, + }); + + return unitStatus; + } + + async reassignIncident( + tenantId: string, + incidentId: string, + newAssignment: AssignmentDto, + reason: string, + performedBy: string + ): Promise { + // Find current assignment + const currentUnit = await this.unitStatusRepository.findOne({ + where: { tenantId, currentIncidentId: incidentId }, + }); + + // Release current unit + if (currentUnit) { + currentUnit.status = UnitStatusEnum.AVAILABLE; + currentUnit.currentIncidentId = undefined; + currentUnit.currentTechnicianIds = []; + await this.unitStatusRepository.save(currentUnit); + } + + // Assign new unit + const newUnit = await this.getUnitStatus(tenantId, newAssignment.unitId); + if (!newUnit) { + throw new Error(`Unit ${newAssignment.unitId} not found`); + } + + if (newUnit.status !== UnitStatusEnum.AVAILABLE) { + throw new Error(`Unit ${newAssignment.unitId} is not available`); + } + + newUnit.status = UnitStatusEnum.ASSIGNED; + newUnit.currentIncidentId = incidentId; + newUnit.currentTechnicianIds = newAssignment.technicianIds || []; + await this.unitStatusRepository.save(newUnit); + + // Log the reassignment + await this.logDispatchAction(tenantId, { + incidentId, + action: DispatchAction.REASSIGNED, + fromUnitId: currentUnit?.unitId, + toUnitId: newAssignment.unitId, + fromTechnicianId: currentUnit?.currentTechnicianIds?.[0], + toTechnicianId: newAssignment.technicianIds?.[0], + reason, + performedBy, + }); + + return newUnit; + } + + async updateUnitToEnRoute( + tenantId: string, + unitId: string, + performedBy: string + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, unitId); + if (!unitStatus || !unitStatus.currentIncidentId) return null; + + unitStatus.status = UnitStatusEnum.EN_ROUTE; + await this.unitStatusRepository.save(unitStatus); + + await this.logDispatchAction(tenantId, { + incidentId: unitStatus.currentIncidentId, + action: DispatchAction.ACKNOWLEDGED, + toUnitId: unitId, + performedBy, + }); + + return unitStatus; + } + + async updateUnitToOnSite( + tenantId: string, + unitId: string, + performedBy: string + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, unitId); + if (!unitStatus || !unitStatus.currentIncidentId) return null; + + unitStatus.status = UnitStatusEnum.ON_SITE; + await this.unitStatusRepository.save(unitStatus); + + return unitStatus; + } + + async completeIncident( + tenantId: string, + unitId: string, + performedBy: string + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, unitId); + if (!unitStatus || !unitStatus.currentIncidentId) return null; + + const incidentId = unitStatus.currentIncidentId; + + unitStatus.status = UnitStatusEnum.RETURNING; + await this.unitStatusRepository.save(unitStatus); + + await this.logDispatchAction(tenantId, { + incidentId, + action: DispatchAction.COMPLETED, + toUnitId: unitId, + performedBy, + }); + + return unitStatus; + } + + async releaseUnit( + tenantId: string, + unitId: string + ): Promise { + const unitStatus = await this.getUnitStatus(tenantId, unitId); + if (!unitStatus) return null; + + unitStatus.status = UnitStatusEnum.AVAILABLE; + unitStatus.currentIncidentId = undefined; + unitStatus.currentTechnicianIds = []; + unitStatus.estimatedAvailableAt = undefined; + + return this.unitStatusRepository.save(unitStatus); + } + + // ========================================== + // Assignment Suggestions + // ========================================== + + async suggestBestAssignment( + tenantId: string, + incidentId: string, + incidentLat: number, + incidentLng: number, + requiredSkills?: string[], + requiredCapacity?: UnitCapacity + ): Promise { + const availableUnits = await this.getAvailableUnits(tenantId, { + hasPosition: true, + unitCapacity: requiredCapacity, + }); + + const suggestions: AssignmentSuggestion[] = []; + + for (const unit of availableUnits) { + if (!unit.lastKnownLat || !unit.lastKnownLng) continue; + + const distance = this.calculateDistance( + incidentLat, + incidentLng, + Number(unit.lastKnownLat), + Number(unit.lastKnownLng) + ); + + // Score based on distance (closer = higher score) + let score = Math.max(0, 100 - distance * 2); + const reasons: string[] = []; + + if (distance <= 10) { + reasons.push('Nearby unit (< 10km)'); + } else if (distance <= 25) { + reasons.push('Medium distance (10-25km)'); + } else { + reasons.push(`Far unit (${distance.toFixed(1)}km)`); + score -= 20; + } + + // Capacity bonus + if (requiredCapacity && unit.unitCapacity === requiredCapacity) { + score += 10; + reasons.push(`Matching capacity: ${requiredCapacity}`); + } + + suggestions.push({ + unitId: unit.unitId, + unitCode: unit.unitCode, + unitName: unit.unitName, + score: Math.round(score), + distanceKm: Math.round(distance * 10) / 10, + technicianIds: unit.currentTechnicianIds, + reasons, + }); + } + + // Sort by score descending + return suggestions.sort((a, b) => b.score - a.score).slice(0, 5); + } + + // ========================================== + // Dispatch Log + // ========================================== + + async logDispatchAction( + tenantId: string, + data: { + incidentId: string; + action: DispatchAction; + fromUnitId?: string; + toUnitId?: string; + fromTechnicianId?: string; + toTechnicianId?: string; + reason?: string; + automated?: boolean; + ruleId?: string; + escalationId?: string; + responseTimeSeconds?: number; + performedBy?: string; + metadata?: Record; + } + ): Promise { + const log = this.dispatchLogRepository.create({ + tenantId, + ...data, + performedAt: new Date(), + }); + return this.dispatchLogRepository.save(log); + } + + async getDispatchLogs( + tenantId: string, + incidentId: string + ): Promise { + return this.dispatchLogRepository.find({ + where: { tenantId, incidentId }, + order: { performedAt: 'DESC' }, + }); + } + + async getRecentLogs( + tenantId: string, + limit: number = 50 + ): Promise { + return this.dispatchLogRepository.find({ + where: { tenantId }, + order: { performedAt: 'DESC' }, + take: limit, + }); + } + + // ========================================== + // Statistics + // ========================================== + + async getDispatchStats(tenantId: string): Promise<{ + totalUnits: number; + available: number; + assigned: number; + enRoute: number; + onSite: number; + offline: number; + maintenance: number; + }> { + const units = await this.unitStatusRepository.find({ where: { tenantId } }); + + const stats = { + totalUnits: units.length, + available: 0, + assigned: 0, + enRoute: 0, + onSite: 0, + offline: 0, + maintenance: 0, + }; + + for (const unit of units) { + switch (unit.status) { + case UnitStatusEnum.AVAILABLE: + stats.available++; + break; + case UnitStatusEnum.ASSIGNED: + stats.assigned++; + break; + case UnitStatusEnum.EN_ROUTE: + stats.enRoute++; + break; + case UnitStatusEnum.ON_SITE: + stats.onSite++; + break; + case UnitStatusEnum.OFFLINE: + stats.offline++; + break; + case UnitStatusEnum.MAINTENANCE: + stats.maintenance++; + break; + } + } + + return stats; + } + + // ========================================== + // Helpers + // ========================================== + + private calculateDistance( + lat1: number, + lng1: number, + lat2: number, + lng2: number + ): number { + const R = 6371; // Earth's radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * + Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * + Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } +} diff --git a/src/modules/dispatch/services/index.ts b/src/modules/dispatch/services/index.ts new file mode 100644 index 0000000..80b11a8 --- /dev/null +++ b/src/modules/dispatch/services/index.ts @@ -0,0 +1,11 @@ +/** + * Dispatch Module Services Index + * Mecanicas Diesel - ERP Suite + * + * Module: MMD-011 Dispatch Center + */ + +export { DispatchService } from './dispatch.service'; +export { SkillService } from './skill.service'; +export { ShiftService } from './shift.service'; +export { RuleService } from './rule.service'; diff --git a/src/modules/dispatch/services/rule.service.ts b/src/modules/dispatch/services/rule.service.ts new file mode 100644 index 0000000..714dac3 --- /dev/null +++ b/src/modules/dispatch/services/rule.service.ts @@ -0,0 +1,370 @@ +/** + * Rule Service + * Mecanicas Diesel - ERP Suite + * + * Business logic for dispatch and escalation rules. + * Module: MMD-011 Dispatch Center + */ + +import { Repository, DataSource } from 'typeorm'; +import { DispatchRule, DispatchConditions } from '../entities/dispatch-rule.entity'; +import { EscalationRule, NotificationChannel } from '../entities/escalation-rule.entity'; + +// DTOs +export interface CreateDispatchRuleDto { + name: string; + description?: string; + priority?: number; + serviceTypeCode?: string; + incidentCategory?: string; + conditions: DispatchConditions; + autoAssign?: boolean; + assignmentWeight?: number; + createdBy?: string; +} + +export interface UpdateDispatchRuleDto { + name?: string; + description?: string; + priority?: number; + serviceTypeCode?: string; + incidentCategory?: string; + conditions?: DispatchConditions; + autoAssign?: boolean; + assignmentWeight?: number; + isActive?: boolean; +} + +export interface CreateEscalationRuleDto { + name: string; + description?: string; + triggerAfterMinutes: number; + triggerStatus?: string; + triggerPriority?: string; + escalateToRole: string; + escalateToUserIds?: string[]; + notificationChannel: NotificationChannel; + notificationTemplate?: string; + notificationData?: Record; + repeatIntervalMinutes?: number; + maxEscalations?: number; + createdBy?: string; +} + +export interface UpdateEscalationRuleDto { + name?: string; + description?: string; + triggerAfterMinutes?: number; + triggerStatus?: string; + triggerPriority?: string; + escalateToRole?: string; + escalateToUserIds?: string[]; + notificationChannel?: NotificationChannel; + notificationTemplate?: string; + notificationData?: Record; + repeatIntervalMinutes?: number; + maxEscalations?: number; + isActive?: boolean; +} + +export interface RuleMatch { + rule: DispatchRule; + score: number; +} + +export class RuleService { + private dispatchRuleRepository: Repository; + private escalationRuleRepository: Repository; + + constructor(dataSource: DataSource) { + this.dispatchRuleRepository = dataSource.getRepository(DispatchRule); + this.escalationRuleRepository = dataSource.getRepository(EscalationRule); + } + + // ========================================== + // Dispatch Rules + // ========================================== + + async createDispatchRule( + tenantId: string, + dto: CreateDispatchRuleDto + ): Promise { + const rule = this.dispatchRuleRepository.create({ + tenantId, + ...dto, + priority: dto.priority ?? 0, + isActive: true, + }); + return this.dispatchRuleRepository.save(rule); + } + + async getDispatchRuleById( + tenantId: string, + id: string + ): Promise { + return this.dispatchRuleRepository.findOne({ + where: { id, tenantId }, + }); + } + + async updateDispatchRule( + tenantId: string, + id: string, + dto: UpdateDispatchRuleDto + ): Promise { + const rule = await this.getDispatchRuleById(tenantId, id); + if (!rule) return null; + + Object.assign(rule, dto); + return this.dispatchRuleRepository.save(rule); + } + + async deleteDispatchRule(tenantId: string, id: string): Promise { + const result = await this.dispatchRuleRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + async getActiveDispatchRules(tenantId: string): Promise { + return this.dispatchRuleRepository.find({ + where: { tenantId, isActive: true }, + order: { priority: 'DESC' }, + }); + } + + async getMatchingDispatchRules( + tenantId: string, + serviceTypeCode?: string, + incidentCategory?: string + ): Promise { + const qb = this.dispatchRuleRepository + .createQueryBuilder('rule') + .where('rule.tenant_id = :tenantId', { tenantId }) + .andWhere('rule.is_active = :isActive', { isActive: true }); + + // Match rules that apply to this service type or are generic + if (serviceTypeCode) { + qb.andWhere( + '(rule.service_type_code IS NULL OR rule.service_type_code = :serviceTypeCode)', + { serviceTypeCode } + ); + } + + if (incidentCategory) { + qb.andWhere( + '(rule.incident_category IS NULL OR rule.incident_category = :incidentCategory)', + { incidentCategory } + ); + } + + return qb.orderBy('rule.priority', 'DESC').getMany(); + } + + /** + * Evaluate rules and return applicable ones with scores + */ + async evaluateRules( + tenantId: string, + context: { + serviceTypeCode?: string; + incidentCategory?: string; + technicianSkills?: string[]; + unitCapacity?: string; + distanceKm?: number; + zoneCode?: string; + } + ): Promise { + const rules = await this.getMatchingDispatchRules( + tenantId, + context.serviceTypeCode, + context.incidentCategory + ); + + const matches: RuleMatch[] = []; + + for (const rule of rules) { + let score = rule.assignmentWeight; + let applicable = true; + const conditions = rule.conditions; + + // Check skill requirements + if (conditions.requiresSkills && conditions.requiresSkills.length > 0) { + const hasSkills = conditions.requiresSkills.every((skill) => + context.technicianSkills?.includes(skill) + ); + if (!hasSkills) applicable = false; + } + + // Check unit capacity + if (conditions.minUnitCapacity) { + const capacityOrder = ['light', 'medium', 'heavy']; + const minIdx = capacityOrder.indexOf(conditions.minUnitCapacity); + const actualIdx = capacityOrder.indexOf(context.unitCapacity || 'light'); + if (actualIdx < minIdx) applicable = false; + } + + // Check distance + if (conditions.maxDistanceKm && context.distanceKm) { + if (context.distanceKm > conditions.maxDistanceKm) { + applicable = false; + } else { + // Bonus for being within distance + score += Math.round((1 - context.distanceKm / conditions.maxDistanceKm) * 20); + } + } + + // Check zone + if (conditions.zoneCodes && conditions.zoneCodes.length > 0) { + if (!context.zoneCode || !conditions.zoneCodes.includes(context.zoneCode)) { + applicable = false; + } + } + + if (applicable) { + matches.push({ rule, score }); + } + } + + return matches.sort((a, b) => b.score - a.score); + } + + async listDispatchRules( + tenantId: string, + activeOnly: boolean = false, + pagination = { page: 1, limit: 20 } + ) { + const qb = this.dispatchRuleRepository + .createQueryBuilder('rule') + .where('rule.tenant_id = :tenantId', { tenantId }); + + if (activeOnly) { + qb.andWhere('rule.is_active = :isActive', { isActive: true }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await qb + .orderBy('rule.priority', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + // ========================================== + // Escalation Rules + // ========================================== + + async createEscalationRule( + tenantId: string, + dto: CreateEscalationRuleDto + ): Promise { + const rule = this.escalationRuleRepository.create({ + tenantId, + ...dto, + isActive: true, + }); + return this.escalationRuleRepository.save(rule); + } + + async getEscalationRuleById( + tenantId: string, + id: string + ): Promise { + return this.escalationRuleRepository.findOne({ + where: { id, tenantId }, + }); + } + + async updateEscalationRule( + tenantId: string, + id: string, + dto: UpdateEscalationRuleDto + ): Promise { + const rule = await this.getEscalationRuleById(tenantId, id); + if (!rule) return null; + + Object.assign(rule, dto); + return this.escalationRuleRepository.save(rule); + } + + async deleteEscalationRule(tenantId: string, id: string): Promise { + const result = await this.escalationRuleRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + async getActiveEscalationRules(tenantId: string): Promise { + return this.escalationRuleRepository.find({ + where: { tenantId, isActive: true }, + order: { triggerAfterMinutes: 'ASC' }, + }); + } + + /** + * Get escalation rules that should trigger based on elapsed time + */ + async getTriggeredEscalationRules( + tenantId: string, + elapsedMinutes: number, + incidentStatus?: string, + incidentPriority?: string + ): Promise { + const qb = this.escalationRuleRepository + .createQueryBuilder('rule') + .where('rule.tenant_id = :tenantId', { tenantId }) + .andWhere('rule.is_active = :isActive', { isActive: true }) + .andWhere('rule.trigger_after_minutes <= :elapsedMinutes', { elapsedMinutes }); + + if (incidentStatus) { + qb.andWhere( + '(rule.trigger_status IS NULL OR rule.trigger_status = :incidentStatus)', + { incidentStatus } + ); + } + + if (incidentPriority) { + qb.andWhere( + '(rule.trigger_priority IS NULL OR rule.trigger_priority = :incidentPriority)', + { incidentPriority } + ); + } + + return qb.orderBy('rule.trigger_after_minutes', 'ASC').getMany(); + } + + async listEscalationRules( + tenantId: string, + activeOnly: boolean = false, + pagination = { page: 1, limit: 20 } + ) { + const qb = this.escalationRuleRepository + .createQueryBuilder('rule') + .where('rule.tenant_id = :tenantId', { tenantId }); + + if (activeOnly) { + qb.andWhere('rule.is_active = :isActive', { isActive: true }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await qb + .orderBy('rule.trigger_after_minutes', 'ASC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } +} diff --git a/src/modules/dispatch/services/shift.service.ts b/src/modules/dispatch/services/shift.service.ts new file mode 100644 index 0000000..4bf9db7 --- /dev/null +++ b/src/modules/dispatch/services/shift.service.ts @@ -0,0 +1,377 @@ +/** + * Shift Service + * Mecanicas Diesel - ERP Suite + * + * Business logic for technician shift management. + * Module: MMD-011 Dispatch Center + */ + +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { TechnicianShift, ShiftType } from '../entities/technician-shift.entity'; + +// DTOs +export interface CreateShiftDto { + technicianId: string; + shiftDate: Date; + shiftType: ShiftType; + startTime: string; + endTime: string; + isOnCall?: boolean; + onCallPriority?: number; + assignedUnitId?: string; + notes?: string; + createdBy?: string; +} + +export interface UpdateShiftDto { + shiftType?: ShiftType; + startTime?: string; + endTime?: string; + isOnCall?: boolean; + onCallPriority?: number; + assignedUnitId?: string; + isAbsent?: boolean; + absenceReason?: string; + notes?: string; +} + +export interface ShiftFilters { + technicianId?: string; + shiftType?: ShiftType; + dateFrom?: Date; + dateTo?: Date; + isOnCall?: boolean; + assignedUnitId?: string; + isAbsent?: boolean; +} + +export interface TechnicianAvailability { + technicianId: string; + shift?: TechnicianShift; + isAvailable: boolean; + isOnCall: boolean; + reason?: string; +} + +export class ShiftService { + private shiftRepository: Repository; + + constructor(dataSource: DataSource) { + this.shiftRepository = dataSource.getRepository(TechnicianShift); + } + + /** + * Create a new shift + */ + async createShift(tenantId: string, dto: CreateShiftDto): Promise { + // Check for overlapping shifts + const existing = await this.shiftRepository.findOne({ + where: { + tenantId, + technicianId: dto.technicianId, + shiftDate: dto.shiftDate, + }, + }); + + if (existing) { + throw new Error( + `Technician ${dto.technicianId} already has a shift on ${dto.shiftDate}` + ); + } + + const shift = this.shiftRepository.create({ + tenantId, + ...dto, + }); + + return this.shiftRepository.save(shift); + } + + /** + * Get shift by ID + */ + async getById(tenantId: string, id: string): Promise { + return this.shiftRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Update shift + */ + async updateShift( + tenantId: string, + id: string, + dto: UpdateShiftDto + ): Promise { + const shift = await this.getById(tenantId, id); + if (!shift) return null; + + Object.assign(shift, dto); + return this.shiftRepository.save(shift); + } + + /** + * Delete shift + */ + async deleteShift(tenantId: string, id: string): Promise { + const result = await this.shiftRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + /** + * Get shifts for a specific date + */ + async getShiftsForDate( + tenantId: string, + date: Date, + excludeAbsent: boolean = true + ): Promise { + const qb = this.shiftRepository + .createQueryBuilder('shift') + .where('shift.tenant_id = :tenantId', { tenantId }) + .andWhere('shift.shift_date = :date', { date }); + + if (excludeAbsent) { + qb.andWhere('shift.is_absent = :isAbsent', { isAbsent: false }); + } + + return qb.orderBy('shift.start_time', 'ASC').getMany(); + } + + /** + * Get technician's shifts + */ + async getTechnicianShifts( + tenantId: string, + technicianId: string, + dateFrom?: Date, + dateTo?: Date + ): Promise { + const qb = this.shiftRepository + .createQueryBuilder('shift') + .where('shift.tenant_id = :tenantId', { tenantId }) + .andWhere('shift.technician_id = :technicianId', { technicianId }); + + if (dateFrom) { + qb.andWhere('shift.shift_date >= :dateFrom', { dateFrom }); + } + if (dateTo) { + qb.andWhere('shift.shift_date <= :dateTo', { dateTo }); + } + + return qb.orderBy('shift.shift_date', 'ASC').getMany(); + } + + /** + * Get available technicians for a specific date and time + */ + async getAvailableTechnicians( + tenantId: string, + date: Date, + time?: string + ): Promise { + const shifts = await this.getShiftsForDate(tenantId, date, true); + + return shifts + .filter((shift) => { + if (time) { + // Check if time is within shift hours + return time >= shift.startTime && time <= shift.endTime; + } + return true; + }) + .map((shift) => ({ + technicianId: shift.technicianId, + shift, + isAvailable: !shift.isAbsent, + isOnCall: shift.isOnCall, + })); + } + + /** + * Get on-call technicians for a date + */ + async getOnCallTechnicians( + tenantId: string, + date: Date + ): Promise { + return this.shiftRepository.find({ + where: { + tenantId, + shiftDate: date, + isOnCall: true, + isAbsent: false, + }, + order: { onCallPriority: 'ASC' }, + }); + } + + /** + * Mark technician as started shift + */ + async startShift(tenantId: string, id: string): Promise { + const shift = await this.getById(tenantId, id); + if (!shift) return null; + + shift.actualStartTime = new Date(); + return this.shiftRepository.save(shift); + } + + /** + * Mark technician as ended shift + */ + async endShift(tenantId: string, id: string): Promise { + const shift = await this.getById(tenantId, id); + if (!shift) return null; + + shift.actualEndTime = new Date(); + return this.shiftRepository.save(shift); + } + + /** + * Mark technician as absent + */ + async markAbsent( + tenantId: string, + id: string, + reason: string + ): Promise { + const shift = await this.getById(tenantId, id); + if (!shift) return null; + + shift.isAbsent = true; + shift.absenceReason = reason; + return this.shiftRepository.save(shift); + } + + /** + * Assign unit to shift + */ + async assignUnitToShift( + tenantId: string, + id: string, + unitId: string + ): Promise { + const shift = await this.getById(tenantId, id); + if (!shift) return null; + + shift.assignedUnitId = unitId; + return this.shiftRepository.save(shift); + } + + /** + * Get shifts by unit + */ + async getShiftsByUnit( + tenantId: string, + unitId: string, + date: Date + ): Promise { + return this.shiftRepository.find({ + where: { + tenantId, + assignedUnitId: unitId, + shiftDate: date, + isAbsent: false, + }, + order: { startTime: 'ASC' }, + }); + } + + /** + * Generate bulk shifts for a week + */ + async generateWeeklyShifts( + tenantId: string, + technicianId: string, + weekStartDate: Date, + shiftType: ShiftType, + startTime: string, + endTime: string, + daysOfWeek: number[], // 0 = Sunday, 1 = Monday, etc. + createdBy?: string + ): Promise { + const shifts: TechnicianShift[] = []; + + for (let i = 0; i < 7; i++) { + const date = new Date(weekStartDate); + date.setDate(date.getDate() + i); + const dayOfWeek = date.getDay(); + + if (daysOfWeek.includes(dayOfWeek)) { + try { + const shift = await this.createShift(tenantId, { + technicianId, + shiftDate: date, + shiftType, + startTime, + endTime, + createdBy, + }); + shifts.push(shift); + } catch { + // Skip if shift already exists + } + } + } + + return shifts; + } + + /** + * List shifts with filters + */ + async findAll( + tenantId: string, + filters: ShiftFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const qb = this.shiftRepository + .createQueryBuilder('shift') + .where('shift.tenant_id = :tenantId', { tenantId }); + + if (filters.technicianId) { + qb.andWhere('shift.technician_id = :technicianId', { + technicianId: filters.technicianId, + }); + } + if (filters.shiftType) { + qb.andWhere('shift.shift_type = :shiftType', { shiftType: filters.shiftType }); + } + if (filters.dateFrom) { + qb.andWhere('shift.shift_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('shift.shift_date <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.isOnCall !== undefined) { + qb.andWhere('shift.is_on_call = :isOnCall', { isOnCall: filters.isOnCall }); + } + if (filters.assignedUnitId) { + qb.andWhere('shift.assigned_unit_id = :assignedUnitId', { + assignedUnitId: filters.assignedUnitId, + }); + } + if (filters.isAbsent !== undefined) { + qb.andWhere('shift.is_absent = :isAbsent', { isAbsent: filters.isAbsent }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await qb + .orderBy('shift.shift_date', 'DESC') + .addOrderBy('shift.start_time', 'ASC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } +} diff --git a/src/modules/dispatch/services/skill.service.ts b/src/modules/dispatch/services/skill.service.ts new file mode 100644 index 0000000..0c860c1 --- /dev/null +++ b/src/modules/dispatch/services/skill.service.ts @@ -0,0 +1,353 @@ +/** + * Skill Service + * Mecanicas Diesel - ERP Suite + * + * Business logic for technician skills management. + * Module: MMD-011 Dispatch Center + */ + +import { Repository, DataSource, LessThanOrEqual } from 'typeorm'; +import { TechnicianSkill, SkillLevel } from '../entities/technician-skill.entity'; + +// DTOs +export interface CreateSkillDto { + technicianId: string; + skillCode: string; + skillName: string; + skillDescription?: string; + level?: SkillLevel; + certificationNumber?: string; + certifiedAt?: Date; + expiresAt?: Date; + certificationDocumentUrl?: string; +} + +export interface UpdateSkillDto { + skillName?: string; + skillDescription?: string; + level?: SkillLevel; + certificationNumber?: string; + certifiedAt?: Date; + expiresAt?: Date; + certificationDocumentUrl?: string; + isActive?: boolean; +} + +export interface SkillFilters { + technicianId?: string; + skillCode?: string; + level?: SkillLevel; + isActive?: boolean; + expiringWithinDays?: number; +} + +export interface SkillMatrix { + skills: { + code: string; + name: string; + techniciansCount: number; + byLevel: Record; + }[]; + technicians: { + id: string; + skillCount: number; + skills: string[]; + }[]; +} + +export class SkillService { + private skillRepository: Repository; + + constructor(dataSource: DataSource) { + this.skillRepository = dataSource.getRepository(TechnicianSkill); + } + + /** + * Add skill to technician + */ + async addSkill(tenantId: string, dto: CreateSkillDto): Promise { + // Check for existing skill + const existing = await this.skillRepository.findOne({ + where: { + tenantId, + technicianId: dto.technicianId, + skillCode: dto.skillCode, + }, + }); + + if (existing) { + throw new Error( + `Technician ${dto.technicianId} already has skill ${dto.skillCode}` + ); + } + + const skill = this.skillRepository.create({ + tenantId, + ...dto, + level: dto.level || SkillLevel.BASIC, + isActive: true, + }); + + return this.skillRepository.save(skill); + } + + /** + * Get skill by ID + */ + async getById(tenantId: string, id: string): Promise { + return this.skillRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Update skill + */ + async updateSkill( + tenantId: string, + id: string, + dto: UpdateSkillDto + ): Promise { + const skill = await this.getById(tenantId, id); + if (!skill) return null; + + Object.assign(skill, dto); + return this.skillRepository.save(skill); + } + + /** + * Deactivate skill + */ + async deactivateSkill(tenantId: string, id: string): Promise { + const skill = await this.getById(tenantId, id); + if (!skill) return false; + + skill.isActive = false; + await this.skillRepository.save(skill); + return true; + } + + /** + * Get skills for technician + */ + async getTechnicianSkills( + tenantId: string, + technicianId: string, + activeOnly: boolean = true + ): Promise { + const where: any = { tenantId, technicianId }; + if (activeOnly) { + where.isActive = true; + } + + return this.skillRepository.find({ + where, + order: { skillCode: 'ASC' }, + }); + } + + /** + * Get technicians with specific skill + */ + async getTechniciansWithSkill( + tenantId: string, + skillCode: string, + minLevel?: SkillLevel + ): Promise { + const qb = this.skillRepository + .createQueryBuilder('skill') + .where('skill.tenant_id = :tenantId', { tenantId }) + .andWhere('skill.skill_code = :skillCode', { skillCode }) + .andWhere('skill.is_active = :isActive', { isActive: true }); + + if (minLevel) { + const levelOrder = [ + SkillLevel.BASIC, + SkillLevel.INTERMEDIATE, + SkillLevel.ADVANCED, + SkillLevel.EXPERT, + ]; + const minIndex = levelOrder.indexOf(minLevel); + const validLevels = levelOrder.slice(minIndex); + qb.andWhere('skill.level IN (:...levels)', { levels: validLevels }); + } + + return qb.orderBy('skill.level', 'DESC').getMany(); + } + + /** + * Check if technician has required skills + */ + async validateTechnicianSkills( + tenantId: string, + technicianId: string, + requiredSkills: string[], + minLevel?: SkillLevel + ): Promise<{ valid: boolean; missing: string[] }> { + const techSkills = await this.getTechnicianSkills(tenantId, technicianId); + const techSkillCodes = new Set( + techSkills + .filter((s) => { + if (!minLevel) return true; + const levelOrder = [ + SkillLevel.BASIC, + SkillLevel.INTERMEDIATE, + SkillLevel.ADVANCED, + SkillLevel.EXPERT, + ]; + return levelOrder.indexOf(s.level) >= levelOrder.indexOf(minLevel); + }) + .map((s) => s.skillCode) + ); + + const missing = requiredSkills.filter((code) => !techSkillCodes.has(code)); + + return { + valid: missing.length === 0, + missing, + }; + } + + /** + * Get expiring skills + */ + async getExpiringSkills( + tenantId: string, + withinDays: number = 30 + ): Promise { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + withinDays); + + return this.skillRepository + .createQueryBuilder('skill') + .where('skill.tenant_id = :tenantId', { tenantId }) + .andWhere('skill.is_active = :isActive', { isActive: true }) + .andWhere('skill.expires_at IS NOT NULL') + .andWhere('skill.expires_at <= :expiryDate', { expiryDate }) + .orderBy('skill.expires_at', 'ASC') + .getMany(); + } + + /** + * Verify skill (admin approval) + */ + async verifySkill( + tenantId: string, + id: string, + verifiedBy: string + ): Promise { + const skill = await this.getById(tenantId, id); + if (!skill) return null; + + skill.verifiedBy = verifiedBy; + skill.verifiedAt = new Date(); + return this.skillRepository.save(skill); + } + + /** + * Get skill matrix (overview of all skills and technicians) + */ + async getSkillMatrix(tenantId: string): Promise { + const allSkills = await this.skillRepository.find({ + where: { tenantId, isActive: true }, + }); + + // Group by skill code + const skillMap = new Map< + string, + { name: string; technicians: Set; levels: Record } + >(); + + // Group by technician + const technicianMap = new Map>(); + + for (const skill of allSkills) { + // Skill aggregation + if (!skillMap.has(skill.skillCode)) { + skillMap.set(skill.skillCode, { + name: skill.skillName, + technicians: new Set(), + levels: { + [SkillLevel.BASIC]: 0, + [SkillLevel.INTERMEDIATE]: 0, + [SkillLevel.ADVANCED]: 0, + [SkillLevel.EXPERT]: 0, + }, + }); + } + const skillData = skillMap.get(skill.skillCode)!; + skillData.technicians.add(skill.technicianId); + skillData.levels[skill.level]++; + + // Technician aggregation + if (!technicianMap.has(skill.technicianId)) { + technicianMap.set(skill.technicianId, new Set()); + } + technicianMap.get(skill.technicianId)!.add(skill.skillCode); + } + + return { + skills: Array.from(skillMap.entries()).map(([code, data]) => ({ + code, + name: data.name, + techniciansCount: data.technicians.size, + byLevel: data.levels, + })), + technicians: Array.from(technicianMap.entries()).map(([id, skills]) => ({ + id, + skillCount: skills.size, + skills: Array.from(skills), + })), + }; + } + + /** + * List all skills with filters + */ + async findAll( + tenantId: string, + filters: SkillFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const qb = this.skillRepository + .createQueryBuilder('skill') + .where('skill.tenant_id = :tenantId', { tenantId }); + + if (filters.technicianId) { + qb.andWhere('skill.technician_id = :technicianId', { + technicianId: filters.technicianId, + }); + } + if (filters.skillCode) { + qb.andWhere('skill.skill_code = :skillCode', { skillCode: filters.skillCode }); + } + if (filters.level) { + qb.andWhere('skill.level = :level', { level: filters.level }); + } + if (filters.isActive !== undefined) { + qb.andWhere('skill.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.expiringWithinDays) { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + filters.expiringWithinDays); + qb.andWhere('skill.expires_at IS NOT NULL'); + qb.andWhere('skill.expires_at <= :expiryDate', { expiryDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await qb + .orderBy('skill.skill_code', 'ASC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } +}