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 <noreply@anthropic.com>
This commit is contained in:
parent
89948663e9
commit
7e0d4ee841
36
src/main.ts
36
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',
|
||||
});
|
||||
|
||||
362
src/modules/dispatch/controllers/dispatch.controller.ts
Normal file
362
src/modules/dispatch/controllers/dispatch.controller.ts
Normal file
@ -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;
|
||||
}
|
||||
11
src/modules/dispatch/controllers/index.ts
Normal file
11
src/modules/dispatch/controllers/index.ts
Normal file
@ -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';
|
||||
259
src/modules/dispatch/controllers/rule.controller.ts
Normal file
259
src/modules/dispatch/controllers/rule.controller.ts
Normal file
@ -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;
|
||||
}
|
||||
304
src/modules/dispatch/controllers/shift.controller.ts
Normal file
304
src/modules/dispatch/controllers/shift.controller.ts
Normal file
@ -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;
|
||||
}
|
||||
223
src/modules/dispatch/controllers/skill.controller.ts
Normal file
223
src/modules/dispatch/controllers/skill.controller.ts
Normal file
@ -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;
|
||||
}
|
||||
71
src/modules/dispatch/entities/dispatch-board.entity.ts
Normal file
71
src/modules/dispatch/entities/dispatch-board.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
90
src/modules/dispatch/entities/dispatch-log.entity.ts
Normal file
90
src/modules/dispatch/entities/dispatch-log.entity.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
76
src/modules/dispatch/entities/dispatch-rule.entity.ts
Normal file
76
src/modules/dispatch/entities/dispatch-rule.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
91
src/modules/dispatch/entities/escalation-rule.entity.ts
Normal file
91
src/modules/dispatch/entities/escalation-rule.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
14
src/modules/dispatch/entities/index.ts
Normal file
14
src/modules/dispatch/entities/index.ts
Normal file
@ -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';
|
||||
93
src/modules/dispatch/entities/technician-shift.entity.ts
Normal file
93
src/modules/dispatch/entities/technician-shift.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
85
src/modules/dispatch/entities/technician-skill.entity.ts
Normal file
85
src/modules/dispatch/entities/technician-skill.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
111
src/modules/dispatch/entities/unit-status.entity.ts
Normal file
111
src/modules/dispatch/entities/unit-status.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
15
src/modules/dispatch/index.ts
Normal file
15
src/modules/dispatch/index.ts
Normal file
@ -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';
|
||||
540
src/modules/dispatch/services/dispatch.service.ts
Normal file
540
src/modules/dispatch/services/dispatch.service.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<UnitStatus>;
|
||||
private dispatchLogRepository: Repository<DispatchLog>;
|
||||
private dispatchBoardRepository: Repository<DispatchBoard>;
|
||||
|
||||
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<DispatchBoard> {
|
||||
const board = this.dispatchBoardRepository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
});
|
||||
return this.dispatchBoardRepository.save(board);
|
||||
}
|
||||
|
||||
async getBoard(tenantId: string, id: string): Promise<DispatchBoard | null> {
|
||||
return this.dispatchBoardRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveBoard(tenantId: string): Promise<DispatchBoard | null> {
|
||||
return this.dispatchBoardRepository.findOne({
|
||||
where: { tenantId, isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Unit Status Management
|
||||
// ==========================================
|
||||
|
||||
async createUnitStatus(tenantId: string, dto: CreateUnitStatusDto): Promise<UnitStatus> {
|
||||
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<UnitStatus | null> {
|
||||
return this.unitStatusRepository.findOne({
|
||||
where: { tenantId, unitId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateUnitStatus(
|
||||
tenantId: string,
|
||||
unitId: string,
|
||||
dto: UpdateUnitStatusDto
|
||||
): Promise<UnitStatus | null> {
|
||||
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<UnitStatus[]> {
|
||||
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<UnitStatus[]> {
|
||||
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<UnitStatus | null> {
|
||||
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<UnitStatus | null> {
|
||||
// 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<UnitStatus | null> {
|
||||
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<UnitStatus | null> {
|
||||
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<UnitStatus | null> {
|
||||
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<UnitStatus | null> {
|
||||
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<AssignmentSuggestion[]> {
|
||||
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<string, any>;
|
||||
}
|
||||
): Promise<DispatchLog> {
|
||||
const log = this.dispatchLogRepository.create({
|
||||
tenantId,
|
||||
...data,
|
||||
performedAt: new Date(),
|
||||
});
|
||||
return this.dispatchLogRepository.save(log);
|
||||
}
|
||||
|
||||
async getDispatchLogs(
|
||||
tenantId: string,
|
||||
incidentId: string
|
||||
): Promise<DispatchLog[]> {
|
||||
return this.dispatchLogRepository.find({
|
||||
where: { tenantId, incidentId },
|
||||
order: { performedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentLogs(
|
||||
tenantId: string,
|
||||
limit: number = 50
|
||||
): Promise<DispatchLog[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
11
src/modules/dispatch/services/index.ts
Normal file
11
src/modules/dispatch/services/index.ts
Normal file
@ -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';
|
||||
370
src/modules/dispatch/services/rule.service.ts
Normal file
370
src/modules/dispatch/services/rule.service.ts
Normal file
@ -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<string, any>;
|
||||
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<string, any>;
|
||||
repeatIntervalMinutes?: number;
|
||||
maxEscalations?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleMatch {
|
||||
rule: DispatchRule;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export class RuleService {
|
||||
private dispatchRuleRepository: Repository<DispatchRule>;
|
||||
private escalationRuleRepository: Repository<EscalationRule>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.dispatchRuleRepository = dataSource.getRepository(DispatchRule);
|
||||
this.escalationRuleRepository = dataSource.getRepository(EscalationRule);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Dispatch Rules
|
||||
// ==========================================
|
||||
|
||||
async createDispatchRule(
|
||||
tenantId: string,
|
||||
dto: CreateDispatchRuleDto
|
||||
): Promise<DispatchRule> {
|
||||
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<DispatchRule | null> {
|
||||
return this.dispatchRuleRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateDispatchRule(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateDispatchRuleDto
|
||||
): Promise<DispatchRule | null> {
|
||||
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<boolean> {
|
||||
const result = await this.dispatchRuleRepository.delete({ id, tenantId });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
async getActiveDispatchRules(tenantId: string): Promise<DispatchRule[]> {
|
||||
return this.dispatchRuleRepository.find({
|
||||
where: { tenantId, isActive: true },
|
||||
order: { priority: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getMatchingDispatchRules(
|
||||
tenantId: string,
|
||||
serviceTypeCode?: string,
|
||||
incidentCategory?: string
|
||||
): Promise<DispatchRule[]> {
|
||||
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<RuleMatch[]> {
|
||||
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<EscalationRule> {
|
||||
const rule = this.escalationRuleRepository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
isActive: true,
|
||||
});
|
||||
return this.escalationRuleRepository.save(rule);
|
||||
}
|
||||
|
||||
async getEscalationRuleById(
|
||||
tenantId: string,
|
||||
id: string
|
||||
): Promise<EscalationRule | null> {
|
||||
return this.escalationRuleRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateEscalationRule(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateEscalationRuleDto
|
||||
): Promise<EscalationRule | null> {
|
||||
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<boolean> {
|
||||
const result = await this.escalationRuleRepository.delete({ id, tenantId });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
async getActiveEscalationRules(tenantId: string): Promise<EscalationRule[]> {
|
||||
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<EscalationRule[]> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
377
src/modules/dispatch/services/shift.service.ts
Normal file
377
src/modules/dispatch/services/shift.service.ts
Normal file
@ -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<TechnicianShift>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.shiftRepository = dataSource.getRepository(TechnicianShift);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new shift
|
||||
*/
|
||||
async createShift(tenantId: string, dto: CreateShiftDto): Promise<TechnicianShift> {
|
||||
// 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<TechnicianShift | null> {
|
||||
return this.shiftRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update shift
|
||||
*/
|
||||
async updateShift(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateShiftDto
|
||||
): Promise<TechnicianShift | null> {
|
||||
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<boolean> {
|
||||
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<TechnicianShift[]> {
|
||||
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<TechnicianShift[]> {
|
||||
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<TechnicianAvailability[]> {
|
||||
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<TechnicianShift[]> {
|
||||
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<TechnicianShift | null> {
|
||||
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<TechnicianShift | null> {
|
||||
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<TechnicianShift | null> {
|
||||
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<TechnicianShift | null> {
|
||||
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<TechnicianShift[]> {
|
||||
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<TechnicianShift[]> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
353
src/modules/dispatch/services/skill.service.ts
Normal file
353
src/modules/dispatch/services/skill.service.ts
Normal file
@ -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<SkillLevel, number>;
|
||||
}[];
|
||||
technicians: {
|
||||
id: string;
|
||||
skillCount: number;
|
||||
skills: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export class SkillService {
|
||||
private skillRepository: Repository<TechnicianSkill>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.skillRepository = dataSource.getRepository(TechnicianSkill);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add skill to technician
|
||||
*/
|
||||
async addSkill(tenantId: string, dto: CreateSkillDto): Promise<TechnicianSkill> {
|
||||
// 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<TechnicianSkill | null> {
|
||||
return this.skillRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill
|
||||
*/
|
||||
async updateSkill(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateSkillDto
|
||||
): Promise<TechnicianSkill | null> {
|
||||
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<boolean> {
|
||||
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<TechnicianSkill[]> {
|
||||
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<TechnicianSkill[]> {
|
||||
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<TechnicianSkill[]> {
|
||||
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<TechnicianSkill | null> {
|
||||
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<SkillMatrix> {
|
||||
const allSkills = await this.skillRepository.find({
|
||||
where: { tenantId, isActive: true },
|
||||
});
|
||||
|
||||
// Group by skill code
|
||||
const skillMap = new Map<
|
||||
string,
|
||||
{ name: string; technicians: Set<string>; levels: Record<SkillLevel, number> }
|
||||
>();
|
||||
|
||||
// Group by technician
|
||||
const technicianMap = new Map<string, Set<string>>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user