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:
Adrian Flores Cortes 2026-01-27 02:21:40 -06:00
parent 89948663e9
commit 7e0d4ee841
20 changed files with 3492 additions and 0 deletions

View File

@ -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',
});

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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>;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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);
}
}

View 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';

View 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),
};
}
}

View 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),
};
}
}

View 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),
};
}
}