From dad6575a3c985da73e2b15aa01d7cce86f7fef7a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 02:43:03 -0600 Subject: [PATCH] feat(MMD-012): Implement Field Service module - Add 11 entities: ServiceChecklist, ChecklistItemTemplate, ChecklistResponse, ChecklistItemResponse, WorkLog, DiagnosisRecord, ActivityCatalog, RootCauseCatalog, FieldEvidence, FieldCheckin, OfflineQueueItem - Add 5 services: ChecklistService, WorkLogService, DiagnosisService, OfflineSyncService, CheckinService - Add 5 controllers: ChecklistController, WorkLogController, DiagnosisController, SyncController, CheckinController - Integrate module in main.ts with routes under /api/v1/field/* Features: - Configurable checklists with multiple item types - Labor time tracking with pause/resume - Diagnosis with OBD-II code parsing - Offline sync queue with conflict resolution - Technician check-in/check-out with geolocation Co-Authored-By: Claude Opus 4.5 --- src/main.ts | 47 +++ .../controllers/checkin.controller.ts | 175 ++++++++ .../controllers/checklist.controller.ts | 293 +++++++++++++ .../controllers/diagnosis.controller.ts | 259 ++++++++++++ .../field-service/controllers/index.ts | 10 + .../controllers/sync.controller.ts | 169 ++++++++ .../controllers/worklog.controller.ts | 231 +++++++++++ .../entities/activity-catalog.entity.ts | 57 +++ .../checklist-item-response.entity.ts | 79 ++++ .../checklist-item-template.entity.ts | 93 +++++ .../entities/checklist-response.entity.ts | 113 +++++ .../entities/diagnosis-record.entity.ts | 141 +++++++ .../entities/field-checkin.entity.ts | 93 +++++ .../entities/field-evidence.entity.ts | 126 ++++++ src/modules/field-service/entities/index.ts | 16 + .../entities/offline-queue-item.entity.ts | 115 ++++++ .../entities/root-cause-catalog.entity.ts | 60 +++ .../entities/service-checklist.entity.ts | 68 +++ .../field-service/entities/work-log.entity.ts | 125 ++++++ src/modules/field-service/index.ts | 10 + .../field-service/services/checkin.service.ts | 296 +++++++++++++ .../services/checklist.service.ts | 360 ++++++++++++++++ .../services/diagnosis.service.ts | 351 ++++++++++++++++ src/modules/field-service/services/index.ts | 10 + .../services/offline-sync.service.ts | 388 ++++++++++++++++++ .../field-service/services/worklog.service.ts | 359 ++++++++++++++++ 26 files changed, 4044 insertions(+) create mode 100644 src/modules/field-service/controllers/checkin.controller.ts create mode 100644 src/modules/field-service/controllers/checklist.controller.ts create mode 100644 src/modules/field-service/controllers/diagnosis.controller.ts create mode 100644 src/modules/field-service/controllers/index.ts create mode 100644 src/modules/field-service/controllers/sync.controller.ts create mode 100644 src/modules/field-service/controllers/worklog.controller.ts create mode 100644 src/modules/field-service/entities/activity-catalog.entity.ts create mode 100644 src/modules/field-service/entities/checklist-item-response.entity.ts create mode 100644 src/modules/field-service/entities/checklist-item-template.entity.ts create mode 100644 src/modules/field-service/entities/checklist-response.entity.ts create mode 100644 src/modules/field-service/entities/diagnosis-record.entity.ts create mode 100644 src/modules/field-service/entities/field-checkin.entity.ts create mode 100644 src/modules/field-service/entities/field-evidence.entity.ts create mode 100644 src/modules/field-service/entities/index.ts create mode 100644 src/modules/field-service/entities/offline-queue-item.entity.ts create mode 100644 src/modules/field-service/entities/root-cause-catalog.entity.ts create mode 100644 src/modules/field-service/entities/service-checklist.entity.ts create mode 100644 src/modules/field-service/entities/work-log.entity.ts create mode 100644 src/modules/field-service/index.ts create mode 100644 src/modules/field-service/services/checkin.service.ts create mode 100644 src/modules/field-service/services/checklist.service.ts create mode 100644 src/modules/field-service/services/diagnosis.service.ts create mode 100644 src/modules/field-service/services/index.ts create mode 100644 src/modules/field-service/services/offline-sync.service.ts create mode 100644 src/modules/field-service/services/worklog.service.ts diff --git a/src/main.ts b/src/main.ts index d9b9127..7cca810 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,13 @@ import { createSkillController } from './modules/dispatch/controllers/skill.cont import { createShiftController } from './modules/dispatch/controllers/shift.controller'; import { createRuleController } from './modules/dispatch/controllers/rule.controller'; +// Field Service Module Controllers +import { createChecklistController } from './modules/field-service/controllers/checklist.controller'; +import { createWorkLogController } from './modules/field-service/controllers/worklog.controller'; +import { createDiagnosisController as createFieldDiagnosisController } from './modules/field-service/controllers/diagnosis.controller'; +import { createSyncController } from './modules/field-service/controllers/sync.controller'; +import { createCheckinController } from './modules/field-service/controllers/checkin.controller'; + // Payment Terminals Module import { PaymentTerminalsModule } from './modules/payment-terminals'; @@ -99,6 +106,19 @@ 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'; +// Entities - Field Service +import { ServiceChecklist } from './modules/field-service/entities/service-checklist.entity'; +import { ChecklistItemTemplate } from './modules/field-service/entities/checklist-item-template.entity'; +import { ChecklistResponse } from './modules/field-service/entities/checklist-response.entity'; +import { ChecklistItemResponse } from './modules/field-service/entities/checklist-item-response.entity'; +import { WorkLog } from './modules/field-service/entities/work-log.entity'; +import { DiagnosisRecord } from './modules/field-service/entities/diagnosis-record.entity'; +import { ActivityCatalog } from './modules/field-service/entities/activity-catalog.entity'; +import { RootCauseCatalog } from './modules/field-service/entities/root-cause-catalog.entity'; +import { FieldEvidence } from './modules/field-service/entities/field-evidence.entity'; +import { FieldCheckin } from './modules/field-service/entities/field-checkin.entity'; +import { OfflineQueueItem } from './modules/field-service/entities/offline-queue-item.entity'; + // Load environment variables config(); @@ -164,6 +184,18 @@ const AppDataSource = new DataSource({ DispatchRule, EscalationRule, DispatchLog, + // Field Service + ServiceChecklist, + ChecklistItemTemplate, + ChecklistResponse, + ChecklistItemResponse, + WorkLog, + DiagnosisRecord, + ActivityCatalog, + RootCauseCatalog, + FieldEvidence, + FieldCheckin, + OfflineQueueItem, ], synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', @@ -231,6 +263,14 @@ async function bootstrap() { app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource)); console.log('📋 Dispatch module initialized'); + // Field Service Module Routes + app.use('/api/v1/field/checklists', createChecklistController(AppDataSource)); + app.use('/api/v1/field/worklog', createWorkLogController(AppDataSource)); + app.use('/api/v1/field/diagnosis', createFieldDiagnosisController(AppDataSource)); + app.use('/api/v1/field/sync', createSyncController(AppDataSource)); + app.use('/api/v1/field/checkins', createCheckinController(AppDataSource)); + console.log('📱 Field Service module initialized'); + // Payment Terminals Module const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); app.use('/api/v1', paymentTerminals.router); @@ -274,6 +314,13 @@ async function bootstrap() { shifts: '/api/v1/dispatch/shifts', rules: '/api/v1/dispatch/rules', }, + field: { + checklists: '/api/v1/field/checklists', + worklog: '/api/v1/field/worklog', + diagnosis: '/api/v1/field/diagnosis', + sync: '/api/v1/field/sync', + checkins: '/api/v1/field/checkins', + }, }, documentation: '/api/v1/docs', }); diff --git a/src/modules/field-service/controllers/checkin.controller.ts b/src/modules/field-service/controllers/checkin.controller.ts new file mode 100644 index 0000000..3336601 --- /dev/null +++ b/src/modules/field-service/controllers/checkin.controller.ts @@ -0,0 +1,175 @@ +/** + * CheckinController + * Mecanicas Diesel - ERP Suite + * + * REST API for field check-ins and check-outs. + * Module: MMD-012 Field Service + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { CheckinService } from '../services/checkin.service'; + +// Middleware to extract tenant from header +const extractTenant = (req: Request, res: Response, next: Function) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-ID header is required' }); + } + (req as any).tenantId = tenantId; + next(); +}; + +export function createCheckinController(dataSource: DataSource): Router { + const router = Router(); + const service = new CheckinService(dataSource); + + router.use(extractTenant); + + // ======================== + // CHECK-IN/OUT OPERATIONS + // ======================== + + // POST /api/v1/field/checkins - Perform check-in + router.post('/', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkin = await service.checkin(tenantId, req.body); + res.status(201).json({ data: checkin }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checkins/:id + router.get('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkin = await service.getById(tenantId, req.params.id); + + if (!checkin) { + return res.status(404).json({ error: 'Check-in not found' }); + } + + res.json({ data: checkin }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checkins/:id/checkout - Perform check-out + router.post('/:id/checkout', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkin = await service.checkout(tenantId, req.params.id, req.body); + res.json({ data: checkin }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checkins/technician/:technicianId/force-checkout + router.post('/technician/:technicianId/force-checkout', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkin = await service.forceCheckout( + tenantId, + req.params.technicianId, + req.body.reason || 'Forced checkout by admin' + ); + + if (!checkin) { + return res.status(404).json({ error: 'No active check-in found' }); + } + + res.json({ data: checkin }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // QUERIES + // ======================== + + // GET /api/v1/field/checkins/technician/:technicianId/active + router.get('/technician/:technicianId/active', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkin = await service.getActiveCheckin(tenantId, req.params.technicianId); + res.json({ data: checkin }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checkins/technician/:technicianId/location + router.get('/technician/:technicianId/location', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const info = await service.getTechnicianLocationInfo(tenantId, req.params.technicianId); + res.json({ data: info }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checkins/incident/:incidentId + router.get('/incident/:incidentId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkins = await service.getCheckinsForIncident(tenantId, req.params.incidentId); + res.json({ data: checkins }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checkins/technician/:technicianId/history + router.get('/technician/:technicianId/history', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const startDate = new Date(req.query.startDate as string || Date.now() - 7 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const checkins = await service.getTechnicianCheckins( + tenantId, + req.params.technicianId, + startDate, + endDate + ); + res.json({ data: checkins }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checkins/active - Get all active check-ins + router.get('/active', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checkins = await service.getActiveCheckinsForTenant(tenantId); + res.json({ data: checkins }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // STATISTICS + // ======================== + + // GET /api/v1/field/checkins/stats/daily + router.get('/stats/daily', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const date = new Date(req.query.date as string || Date.now()); + const stats = await service.getDailyStats(tenantId, date); + res.json({ data: stats }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +} diff --git a/src/modules/field-service/controllers/checklist.controller.ts b/src/modules/field-service/controllers/checklist.controller.ts new file mode 100644 index 0000000..2a5df51 --- /dev/null +++ b/src/modules/field-service/controllers/checklist.controller.ts @@ -0,0 +1,293 @@ +/** + * ChecklistController + * Mecanicas Diesel - ERP Suite + * + * REST API for checklists and responses. + * Module: MMD-012 Field Service + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { ChecklistService } from '../services/checklist.service'; + +// Middleware to extract tenant from header +const extractTenant = (req: Request, res: Response, next: Function) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-ID header is required' }); + } + (req as any).tenantId = tenantId; + next(); +}; + +export function createChecklistController(dataSource: DataSource): Router { + const router = Router(); + const service = new ChecklistService(dataSource); + + router.use(extractTenant); + + // ======================== + // CHECKLIST TEMPLATES + // ======================== + + // GET /api/v1/field/checklists + router.get('/', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const { isActive, serviceTypeCode } = req.query; + + const checklists = await service.findAllChecklists(tenantId, { + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + serviceTypeCode: serviceTypeCode as string, + }); + + res.json({ data: checklists }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checklists/:id + router.get('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checklist = await service.getChecklistById(tenantId, req.params.id); + + if (!checklist) { + return res.status(404).json({ error: 'Checklist not found' }); + } + + res.json({ data: checklist }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checklists/by-service-type/:serviceTypeCode + router.get('/by-service-type/:serviceTypeCode', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checklist = await service.getChecklistForServiceType( + tenantId, + req.params.serviceTypeCode + ); + + if (!checklist) { + // Try default checklist + const defaultChecklist = await service.getDefaultChecklist(tenantId); + if (!defaultChecklist) { + return res.status(404).json({ error: 'No checklist found for this service type' }); + } + return res.json({ data: defaultChecklist }); + } + + res.json({ data: checklist }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checklists + router.post('/', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checklist = await service.createChecklist(tenantId, req.body); + res.status(201).json({ data: checklist }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // PUT /api/v1/field/checklists/:id + router.put('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const checklist = await service.updateChecklist(tenantId, req.params.id, req.body); + res.json({ data: checklist }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // DELETE /api/v1/field/checklists/:id + router.delete('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.deactivateChecklist(tenantId, req.params.id); + res.status(204).send(); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // CHECKLIST ITEMS + // ======================== + + // POST /api/v1/field/checklists/:id/items + router.post('/:id/items', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const items = Array.isArray(req.body) ? req.body : [req.body]; + const created = await service.addChecklistItems(tenantId, req.params.id, items); + res.status(201).json({ data: created }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // PUT /api/v1/field/checklists/:id/items/:itemId + router.put('/:id/items/:itemId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.updateChecklistItem(tenantId, req.params.itemId, req.body); + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // DELETE /api/v1/field/checklists/:id/items/:itemId + router.delete('/:id/items/:itemId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.deleteChecklistItem(tenantId, req.params.itemId); + res.status(204).send(); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checklists/:id/reorder + router.post('/:id/reorder', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.reorderChecklistItems(tenantId, req.params.id, req.body.items); + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // CHECKLIST RESPONSES + // ======================== + + // POST /api/v1/field/checklists/:id/start + router.post('/:id/start', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const response = await service.startChecklist(tenantId, { + checklistId: req.params.id, + ...req.body, + }); + res.status(201).json({ data: response }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checklists/responses/:responseId + router.get('/responses/:responseId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const response = await service.getChecklistResponse(tenantId, req.params.responseId); + + if (!response) { + return res.status(404).json({ error: 'Response not found' }); + } + + res.json({ data: response }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checklists/responses/:responseId/respond + router.post('/responses/:responseId/respond', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const itemResponse = await service.saveItemResponse( + tenantId, + req.params.responseId, + req.body + ); + res.json({ data: itemResponse }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checklists/responses/:responseId/complete + router.post('/responses/:responseId/complete', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const response = await service.completeChecklist( + tenantId, + req.params.responseId, + req.body.endLocation + ); + res.json({ data: response }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/checklists/responses/:responseId/cancel + router.post('/responses/:responseId/cancel', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.cancelChecklist(tenantId, req.params.responseId); + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checklists/by-incident/:incidentId + router.get('/by-incident/:incidentId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const responses = await service.getChecklistResponsesForIncident( + tenantId, + req.params.incidentId + ); + res.json({ data: responses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // STATISTICS + // ======================== + + // GET /api/v1/field/checklists/:id/stats + router.get('/:id/stats', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const stats = await service.getChecklistStats(tenantId, req.params.id); + res.json({ data: stats }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/checklists/technician/:technicianId/history + router.get('/technician/:technicianId/history', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const limit = parseInt(req.query.limit as string) || 20; + const history = await service.getTechnicianChecklistHistory( + tenantId, + req.params.technicianId, + limit + ); + res.json({ data: history }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +} diff --git a/src/modules/field-service/controllers/diagnosis.controller.ts b/src/modules/field-service/controllers/diagnosis.controller.ts new file mode 100644 index 0000000..157e8f5 --- /dev/null +++ b/src/modules/field-service/controllers/diagnosis.controller.ts @@ -0,0 +1,259 @@ +/** + * DiagnosisController + * Mecanicas Diesel - ERP Suite + * + * REST API for diagnoses and root causes. + * Module: MMD-012 Field Service + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { DiagnosisService } from '../services/diagnosis.service'; +import { DiagnosisType, DiagnosisSeverity } from '../entities'; + +// Middleware to extract tenant from header +const extractTenant = (req: Request, res: Response, next: Function) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-ID header is required' }); + } + (req as any).tenantId = tenantId; + next(); +}; + +export function createDiagnosisController(dataSource: DataSource): Router { + const router = Router(); + const service = new DiagnosisService(dataSource); + + router.use(extractTenant); + + // ======================== + // DIAGNOSIS RECORDS + // ======================== + + // POST /api/v1/field/diagnosis + router.post('/', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnosis = await service.createDiagnosis(tenantId, req.body); + res.status(201).json({ data: diagnosis }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/:id + router.get('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnosis = await service.getById(tenantId, req.params.id); + + if (!diagnosis) { + return res.status(404).json({ error: 'Diagnosis not found' }); + } + + res.json({ data: diagnosis }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // PUT /api/v1/field/diagnosis/:id + router.put('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnosis = await service.updateDiagnosis(tenantId, req.params.id, req.body); + res.json({ data: diagnosis }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // DELETE /api/v1/field/diagnosis/:id + router.delete('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.deleteDiagnosis(tenantId, req.params.id); + res.status(204).send(); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/incident/:incidentId + router.get('/incident/:incidentId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnoses = await service.getDiagnosesForIncident(tenantId, req.params.incidentId); + res.json({ data: diagnoses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/vehicle/:vehicleId + router.get('/vehicle/:vehicleId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnoses = await service.getDiagnosesForVehicle(tenantId, req.params.vehicleId); + res.json({ data: diagnoses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/search + router.get('/search', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const { + technicianId, + diagnosisType, + severity, + startDate, + endDate, + limit, + } = req.query; + + const diagnoses = await service.findDiagnoses(tenantId, { + technicianId: technicianId as string, + diagnosisType: diagnosisType as DiagnosisType, + severity: severity as DiagnosisSeverity, + startDate: startDate ? new Date(startDate as string) : undefined, + endDate: endDate ? new Date(endDate as string) : undefined, + limit: limit ? parseInt(limit as string) : undefined, + }); + + res.json({ data: diagnoses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/critical + router.get('/critical', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const diagnoses = await service.getCriticalDiagnoses(tenantId); + res.json({ data: diagnoses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // OBD-II CODES + // ======================== + + // POST /api/v1/field/diagnosis/parse-obd2 + router.post('/parse-obd2', async (req: Request, res: Response) => { + try { + const codes = req.body.codes || []; + const parsed = await service.parseObd2Codes(codes); + res.json({ data: parsed }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // ROOT CAUSE CATALOG + // ======================== + + // GET /api/v1/field/diagnosis/root-causes + router.get('/root-causes', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const { category, isActive } = req.query; + + const rootCauses = await service.getRootCauseCatalog(tenantId, { + category: category as string, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + + res.json({ data: rootCauses }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/root-causes/categories + router.get('/root-causes/categories', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const categories = await service.getRootCauseCategories(tenantId); + res.json({ data: categories }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/diagnosis/root-causes + router.post('/root-causes', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const rootCause = await service.createRootCause(tenantId, req.body); + res.status(201).json({ data: rootCause }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/diagnosis/root-causes/:code + router.get('/root-causes/:code', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const rootCause = await service.getRootCauseByCode(tenantId, req.params.code); + + if (!rootCause) { + return res.status(404).json({ error: 'Root cause not found' }); + } + + res.json({ data: rootCause }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // PUT /api/v1/field/diagnosis/root-causes/:id + router.put('/root-causes/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.updateRootCause(tenantId, req.params.id, req.body); + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // DELETE /api/v1/field/diagnosis/root-causes/:id + router.delete('/root-causes/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.deactivateRootCause(tenantId, req.params.id); + res.status(204).send(); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // STATISTICS + // ======================== + + // GET /api/v1/field/diagnosis/stats + router.get('/stats', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await service.getDiagnosisStats(tenantId, startDate, endDate); + res.json({ data: stats }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +} diff --git a/src/modules/field-service/controllers/index.ts b/src/modules/field-service/controllers/index.ts new file mode 100644 index 0000000..0f8d391 --- /dev/null +++ b/src/modules/field-service/controllers/index.ts @@ -0,0 +1,10 @@ +/** + * Field Service Controllers Index + * Module: MMD-012 Field Service + */ + +export * from './checklist.controller'; +export * from './worklog.controller'; +export * from './diagnosis.controller'; +export * from './sync.controller'; +export * from './checkin.controller'; diff --git a/src/modules/field-service/controllers/sync.controller.ts b/src/modules/field-service/controllers/sync.controller.ts new file mode 100644 index 0000000..fdf5c43 --- /dev/null +++ b/src/modules/field-service/controllers/sync.controller.ts @@ -0,0 +1,169 @@ +/** + * SyncController + * Mecanicas Diesel - ERP Suite + * + * REST API for offline data synchronization. + * Module: MMD-012 Field Service + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { OfflineSyncService } from '../services/offline-sync.service'; +import { SyncResolution } from '../entities/offline-queue-item.entity'; + +// Middleware to extract tenant from header +const extractTenant = (req: Request, res: Response, next: Function) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-ID header is required' }); + } + (req as any).tenantId = tenantId; + next(); +}; + +export function createSyncController(dataSource: DataSource): Router { + const router = Router(); + const service = new OfflineSyncService(dataSource); + + router.use(extractTenant); + + // ======================== + // QUEUE MANAGEMENT + // ======================== + + // POST /api/v1/field/sync/queue - Add item to sync queue + router.post('/queue', async (req: Request, res: Response) => { + try { + const queueItem = await service.queueForSync(req.body); + res.status(201).json({ data: queueItem }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/sync/queue/batch - Add multiple items to queue + router.post('/queue/batch', async (req: Request, res: Response) => { + try { + const items = req.body.items || []; + const results = []; + + for (const item of items) { + const queueItem = await service.queueForSync(item); + results.push(queueItem); + } + + res.status(201).json({ data: results }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/sync/queue/:deviceId - Get queued items for device + router.get('/queue/:deviceId', async (req: Request, res: Response) => { + try { + const items = await service.getQueuedItems(req.params.deviceId); + res.json({ data: items }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/sync/queue/:deviceId/count - Get pending count + router.get('/queue/:deviceId/count', async (req: Request, res: Response) => { + try { + const count = await service.getPendingCount(req.params.deviceId); + res.json({ data: { count } }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // SYNC PROCESSING + // ======================== + + // POST /api/v1/field/sync/process/:deviceId - Process sync queue + router.post('/process/:deviceId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const result = await service.processQueue(req.params.deviceId, tenantId); + res.json({ data: result }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/sync/retry/:deviceId - Retry failed items + router.post('/retry/:deviceId', async (req: Request, res: Response) => { + try { + const count = await service.retryFailedItems(req.params.deviceId); + res.json({ data: { retriedCount: count } }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // CONFLICT RESOLUTION + // ======================== + + // GET /api/v1/field/sync/conflicts/:deviceId - Get conflicts + router.get('/conflicts/:deviceId', async (req: Request, res: Response) => { + try { + const conflicts = await service.getConflicts(req.params.deviceId); + res.json({ data: conflicts }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/sync/conflicts/:itemId/resolve - Resolve conflict + router.post('/conflicts/:itemId/resolve', async (req: Request, res: Response) => { + try { + const { resolution, mergedData } = req.body; + + if (!resolution || !Object.values(SyncResolution).includes(resolution)) { + return res.status(400).json({ + error: 'Invalid resolution. Must be LOCAL, SERVER, or MERGED', + }); + } + + await service.resolveConflict( + req.params.itemId, + resolution as SyncResolution, + mergedData + ); + + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // CLEANUP & STATS + // ======================== + + // POST /api/v1/field/sync/cleanup - Cleanup old completed items + router.post('/cleanup', async (req: Request, res: Response) => { + try { + const olderThanDays = parseInt(req.query.olderThanDays as string) || 7; + const deletedCount = await service.cleanupCompletedItems(olderThanDays); + res.json({ data: { deletedCount } }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/sync/stats/:deviceId - Get sync statistics + router.get('/stats/:deviceId', async (req: Request, res: Response) => { + try { + const stats = await service.getSyncStats(req.params.deviceId); + res.json({ data: stats }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +} diff --git a/src/modules/field-service/controllers/worklog.controller.ts b/src/modules/field-service/controllers/worklog.controller.ts new file mode 100644 index 0000000..438ff80 --- /dev/null +++ b/src/modules/field-service/controllers/worklog.controller.ts @@ -0,0 +1,231 @@ +/** + * WorkLogController + * Mecanicas Diesel - ERP Suite + * + * REST API for labor time tracking. + * Module: MMD-012 Field Service + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { WorkLogService } from '../services/worklog.service'; + +// Middleware to extract tenant from header +const extractTenant = (req: Request, res: Response, next: Function) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-ID header is required' }); + } + (req as any).tenantId = tenantId; + next(); +}; + +export function createWorkLogController(dataSource: DataSource): Router { + const router = Router(); + const service = new WorkLogService(dataSource); + + router.use(extractTenant); + + // ======================== + // WORK LOG OPERATIONS + // ======================== + + // POST /api/v1/field/worklog - Start work + router.post('/', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.startWork(tenantId, req.body); + res.status(201).json({ data: workLog }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/:id + router.get('/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.getById(tenantId, req.params.id); + + if (!workLog) { + return res.status(404).json({ error: 'Work log not found' }); + } + + res.json({ data: workLog }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/worklog/:id/pause + router.post('/:id/pause', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.pauseWork(tenantId, req.params.id); + res.json({ data: workLog }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // POST /api/v1/field/worklog/:id/resume + router.post('/:id/resume', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.resumeWork(tenantId, req.params.id); + res.json({ data: workLog }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // POST /api/v1/field/worklog/:id/stop + router.post('/:id/stop', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.endWork(tenantId, req.params.id, req.body.notes); + res.json({ data: workLog }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // POST /api/v1/field/worklog/:id/cancel + router.post('/:id/cancel', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.cancelWork(tenantId, req.params.id, req.body.reason); + res.json({ success: true }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/technician/:technicianId/active + router.get('/technician/:technicianId/active', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLog = await service.getActiveWorkForTechnician( + tenantId, + req.params.technicianId + ); + res.json({ data: workLog }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/incident/:incidentId + router.get('/incident/:incidentId', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const workLogs = await service.getWorkLogsForIncident(tenantId, req.params.incidentId); + res.json({ data: workLogs }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/incident/:incidentId/summary + router.get('/incident/:incidentId/summary', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const summary = await service.calculateLabor(tenantId, req.params.incidentId); + res.json({ data: summary }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/technician/:technicianId/history + router.get('/technician/:technicianId/history', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const startDate = new Date(req.query.startDate as string || Date.now() - 7 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const workLogs = await service.getTechnicianWorkHistory( + tenantId, + req.params.technicianId, + startDate, + endDate + ); + res.json({ data: workLogs }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ======================== + // ACTIVITY CATALOG + // ======================== + + // GET /api/v1/field/worklog/activities + router.get('/activities', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const { category, serviceTypeCode, isActive } = req.query; + + const activities = await service.getActivities(tenantId, { + category: category as string, + serviceTypeCode: serviceTypeCode as string, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + + res.json({ data: activities }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // POST /api/v1/field/worklog/activities + router.post('/activities', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const activity = await service.createActivity(tenantId, req.body); + res.status(201).json({ data: activity }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/v1/field/worklog/activities/:code + router.get('/activities/:code', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + const activity = await service.getActivityByCode(tenantId, req.params.code); + + if (!activity) { + return res.status(404).json({ error: 'Activity not found' }); + } + + res.json({ data: activity }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // PUT /api/v1/field/worklog/activities/:id + router.put('/activities/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.updateActivity(tenantId, req.params.id, req.body); + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // DELETE /api/v1/field/worklog/activities/:id + router.delete('/activities/:id', async (req: Request, res: Response) => { + try { + const tenantId = (req as any).tenantId; + await service.deactivateActivity(tenantId, req.params.id); + res.status(204).send(); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +} diff --git a/src/modules/field-service/entities/activity-catalog.entity.ts b/src/modules/field-service/entities/activity-catalog.entity.ts new file mode 100644 index 0000000..455a16e --- /dev/null +++ b/src/modules/field-service/entities/activity-catalog.entity.ts @@ -0,0 +1,57 @@ +/** + * ActivityCatalog Entity + * Mecanicas Diesel - ERP Suite + * + * Catalog of labor activities. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'activity_catalog', schema: 'field_service' }) +@Index('idx_activity_tenant', ['tenantId']) +@Index('idx_activity_code', ['tenantId', 'code'], { unique: true }) +export class ActivityCatalog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category?: string; + + @Column({ name: 'service_type_code', type: 'varchar', length: 50, nullable: true }) + serviceTypeCode?: string; + + @Column({ name: 'default_duration_minutes', type: 'integer', nullable: true }) + defaultDurationMinutes?: number; + + @Column({ name: 'default_rate', type: 'decimal', precision: 10, scale: 2, nullable: true }) + defaultRate?: 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; +} diff --git a/src/modules/field-service/entities/checklist-item-response.entity.ts b/src/modules/field-service/entities/checklist-item-response.entity.ts new file mode 100644 index 0000000..a93861f --- /dev/null +++ b/src/modules/field-service/entities/checklist-item-response.entity.ts @@ -0,0 +1,79 @@ +/** + * ChecklistItemResponse Entity + * Mecanicas Diesel - ERP Suite + * + * Individual item responses for checklists. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ChecklistResponse } from './checklist-response.entity'; + +@Entity({ name: 'checklist_item_responses', schema: 'field_service' }) +@Index('idx_item_resp_response', ['checklistResponseId']) +export class ChecklistItemResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'checklist_response_id', type: 'uuid' }) + checklistResponseId: string; + + @ManyToOne(() => ChecklistResponse, (response) => response.itemResponses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'checklist_response_id' }) + checklistResponse: ChecklistResponse; + + @Column({ name: 'item_template_id', type: 'uuid' }) + itemTemplateId: string; + + // Response values by type + @Column({ name: 'value_boolean', type: 'boolean', nullable: true }) + valueBoolean?: boolean; + + @Column({ name: 'value_text', type: 'text', nullable: true }) + valueText?: string; + + @Column({ name: 'value_number', type: 'decimal', precision: 12, scale: 4, nullable: true }) + valueNumber?: number; + + @Column({ name: 'value_json', type: 'jsonb', nullable: true }) + valueJson?: any; + + // Evidence + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl?: string; + + @Column({ name: 'photo_urls', type: 'jsonb', nullable: true }) + photoUrls?: string[]; + + @Column({ name: 'signature_url', type: 'varchar', length: 500, nullable: true }) + signatureUrl?: string; + + // Status + @Column({ name: 'is_passed', type: 'boolean', nullable: true }) + isPassed?: boolean; + + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason?: string; + + // Timing + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + // Offline + @Column({ name: 'local_id', type: 'varchar', length: 100, nullable: true }) + localId?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/field-service/entities/checklist-item-template.entity.ts b/src/modules/field-service/entities/checklist-item-template.entity.ts new file mode 100644 index 0000000..f68b802 --- /dev/null +++ b/src/modules/field-service/entities/checklist-item-template.entity.ts @@ -0,0 +1,93 @@ +/** + * ChecklistItemTemplate Entity + * Mecanicas Diesel - ERP Suite + * + * Template items for service checklists. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ServiceChecklist } from './service-checklist.entity'; + +export enum ChecklistItemType { + BOOLEAN = 'BOOLEAN', + TEXT = 'TEXT', + NUMBER = 'NUMBER', + PHOTO = 'PHOTO', + SIGNATURE = 'SIGNATURE', + SELECT = 'SELECT', +} + +@Entity({ name: 'checklist_item_templates', schema: 'field_service' }) +@Index('idx_checklist_items_checklist', ['checklistId']) +@Index('idx_checklist_items_order', ['checklistId', 'itemOrder']) +export class ChecklistItemTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'checklist_id', type: 'uuid' }) + checklistId: string; + + @ManyToOne(() => ServiceChecklist, (checklist) => checklist.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'checklist_id' }) + checklist: ServiceChecklist; + + @Column({ name: 'item_order', type: 'integer', default: 0 }) + itemOrder: number; + + @Column({ type: 'varchar', length: 500 }) + text: string; + + @Column({ + name: 'item_type', + type: 'varchar', + length: 20, + default: ChecklistItemType.BOOLEAN, + }) + itemType: ChecklistItemType; + + @Column({ name: 'is_required', type: 'boolean', default: false }) + isRequired: boolean; + + @Column({ type: 'jsonb', nullable: true }) + options?: string[]; + + @Column({ type: 'varchar', length: 20, nullable: true }) + unit?: string; + + @Column({ name: 'min_value', type: 'decimal', precision: 12, scale: 2, nullable: true }) + minValue?: number; + + @Column({ name: 'max_value', type: 'decimal', precision: 12, scale: 2, nullable: true }) + maxValue?: number; + + @Column({ name: 'help_text', type: 'varchar', length: 500, nullable: true }) + helpText?: string; + + @Column({ name: 'photo_required', type: 'boolean', default: false }) + photoRequired: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + section?: string; + + @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; +} diff --git a/src/modules/field-service/entities/checklist-response.entity.ts b/src/modules/field-service/entities/checklist-response.entity.ts new file mode 100644 index 0000000..849ef2b --- /dev/null +++ b/src/modules/field-service/entities/checklist-response.entity.ts @@ -0,0 +1,113 @@ +/** + * ChecklistResponse Entity + * Mecanicas Diesel - ERP Suite + * + * Captured checklist responses during field service. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ChecklistItemResponse } from './checklist-item-response.entity'; + +export enum ChecklistStatus { + STARTED = 'STARTED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +@Entity({ name: 'checklist_responses', schema: 'field_service' }) +@Index('idx_checklist_resp_tenant', ['tenantId']) +@Index('idx_checklist_resp_incident', ['tenantId', 'incidentId']) +@Index('idx_checklist_resp_technician', ['tenantId', 'technicianId']) +export class ChecklistResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ name: 'service_order_id', type: 'uuid', nullable: true }) + serviceOrderId?: string; + + @Column({ name: 'checklist_id', type: 'uuid' }) + checklistId: string; + + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + @Column({ + type: 'varchar', + length: 20, + default: ChecklistStatus.STARTED, + }) + status: ChecklistStatus; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + // Location at start/end + @Column({ name: 'start_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + startLatitude?: number; + + @Column({ name: 'start_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + startLongitude?: number; + + @Column({ name: 'end_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + endLatitude?: number; + + @Column({ name: 'end_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + endLongitude?: number; + + // Offline handling + @Column({ name: 'is_offline', type: 'boolean', default: false }) + isOffline: boolean; + + @Column({ name: 'device_id', type: 'varchar', length: 100, nullable: true }) + deviceId?: string; + + @Column({ name: 'local_id', type: 'varchar', length: 100, nullable: true }) + localId?: string; + + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + // Summary + @Column({ name: 'total_items', type: 'integer', default: 0 }) + totalItems: number; + + @Column({ name: 'completed_items', type: 'integer', default: 0 }) + completedItems: number; + + @Column({ name: 'passed_items', type: 'integer', default: 0 }) + passedItems: number; + + @Column({ name: 'failed_items', type: 'integer', default: 0 }) + failedItems: number; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @OneToMany(() => ChecklistItemResponse, (item) => item.checklistResponse) + itemResponses: ChecklistItemResponse[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/entities/diagnosis-record.entity.ts b/src/modules/field-service/entities/diagnosis-record.entity.ts new file mode 100644 index 0000000..9ad6472 --- /dev/null +++ b/src/modules/field-service/entities/diagnosis-record.entity.ts @@ -0,0 +1,141 @@ +/** + * DiagnosisRecord Entity + * Mecanicas Diesel - ERP Suite + * + * Diagnosis and root cause records. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum DiagnosisType { + VISUAL = 'VISUAL', + OBD = 'OBD', + ELECTRONIC = 'ELECTRONIC', + MECHANICAL = 'MECHANICAL', +} + +export enum DiagnosisSeverity { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + CRITICAL = 'CRITICAL', +} + +@Entity({ name: 'diagnosis_records', schema: 'field_service' }) +@Index('idx_diagnosis_tenant', ['tenantId']) +@Index('idx_diagnosis_incident', ['tenantId', 'incidentId']) +@Index('idx_diagnosis_vehicle', ['tenantId', 'vehicleId']) +@Index('idx_diagnosis_technician', ['tenantId', 'technicianId']) +export class DiagnosisRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ name: 'service_order_id', type: 'uuid', nullable: true }) + serviceOrderId?: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string; + + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + // Diagnosis type + @Column({ + name: 'diagnosis_type', + type: 'varchar', + length: 20, + default: DiagnosisType.VISUAL, + }) + diagnosisType: DiagnosisType; + + // Symptoms + @Column({ type: 'text' }) + symptoms: string; + + @Column({ name: 'customer_complaint', type: 'text', nullable: true }) + customerComplaint?: string; + + // Root cause + @Column({ name: 'root_cause_code', type: 'varchar', length: 50, nullable: true }) + rootCauseCode?: string; + + @Column({ name: 'root_cause_category', type: 'varchar', length: 100, nullable: true }) + rootCauseCategory?: string; + + @Column({ name: 'root_cause_description', type: 'text', nullable: true }) + rootCauseDescription?: string; + + // OBD-II codes + @Column({ name: 'obd2_codes', type: 'jsonb', nullable: true }) + obd2Codes?: string[]; + + @Column({ name: 'obd2_raw_data', type: 'jsonb', nullable: true }) + obd2RawData?: Record; + + // Readings + @Column({ name: 'odometer_reading', type: 'decimal', precision: 12, scale: 2, nullable: true }) + odometerReading?: number; + + @Column({ name: 'engine_hours', type: 'decimal', precision: 10, scale: 2, nullable: true }) + engineHours?: number; + + @Column({ name: 'fuel_level', type: 'integer', nullable: true }) + fuelLevel?: number; + + // Recommendation + @Column({ type: 'text', nullable: true }) + recommendation?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + severity?: DiagnosisSeverity; + + @Column({ name: 'requires_immediate_action', type: 'boolean', default: false }) + requiresImmediateAction: boolean; + + // Evidence + @Column({ name: 'photo_urls', type: 'jsonb', nullable: true }) + photoUrls?: string[]; + + @Column({ name: 'video_urls', type: 'jsonb', nullable: true }) + videoUrls?: string[]; + + // Location + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + latitude?: number; + + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + longitude?: number; + + // Offline + @Column({ name: 'is_offline', type: 'boolean', default: false }) + isOffline: boolean; + + @Column({ name: 'device_id', type: 'varchar', length: 100, nullable: true }) + deviceId?: string; + + @Column({ name: 'local_id', type: 'varchar', length: 100, nullable: true }) + localId?: string; + + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/entities/field-checkin.entity.ts b/src/modules/field-service/entities/field-checkin.entity.ts new file mode 100644 index 0000000..cd80ca4 --- /dev/null +++ b/src/modules/field-service/entities/field-checkin.entity.ts @@ -0,0 +1,93 @@ +/** + * FieldCheckin Entity + * Mecanicas Diesel - ERP Suite + * + * Technician check-in/check-out at service sites. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'field_checkins', schema: 'field_service' }) +@Index('idx_checkins_tenant', ['tenantId']) +@Index('idx_checkins_incident', ['tenantId', 'incidentId']) +@Index('idx_checkins_technician', ['tenantId', 'technicianId']) +@Index('idx_checkins_date', ['tenantId', 'checkinTime']) +export class FieldCheckin { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ name: 'service_order_id', type: 'uuid', nullable: true }) + serviceOrderId?: string; + + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + @Column({ name: 'unit_id', type: 'uuid', nullable: true }) + unitId?: string; + + // Checkin + @Column({ name: 'checkin_time', type: 'timestamptz' }) + checkinTime: Date; + + @Column({ name: 'checkin_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + checkinLatitude?: number; + + @Column({ name: 'checkin_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + checkinLongitude?: number; + + @Column({ name: 'checkin_address', type: 'text', nullable: true }) + checkinAddress?: string; + + @Column({ name: 'checkin_photo_url', type: 'varchar', length: 500, nullable: true }) + checkinPhotoUrl?: string; + + // Checkout + @Column({ name: 'checkout_time', type: 'timestamptz', nullable: true }) + checkoutTime?: Date; + + @Column({ name: 'checkout_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + checkoutLatitude?: number; + + @Column({ name: 'checkout_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true }) + checkoutLongitude?: number; + + @Column({ name: 'checkout_photo_url', type: 'varchar', length: 500, nullable: true }) + checkoutPhotoUrl?: string; + + // Duration + @Column({ name: 'on_site_minutes', type: 'integer', nullable: true }) + onSiteMinutes?: number; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + // Offline + @Column({ name: 'is_offline', type: 'boolean', default: false }) + isOffline: boolean; + + @Column({ name: 'device_id', type: 'varchar', length: 100, nullable: true }) + deviceId?: string; + + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/entities/field-evidence.entity.ts b/src/modules/field-service/entities/field-evidence.entity.ts new file mode 100644 index 0000000..21d7e4f --- /dev/null +++ b/src/modules/field-service/entities/field-evidence.entity.ts @@ -0,0 +1,126 @@ +/** + * FieldEvidence Entity + * Mecanicas Diesel - ERP Suite + * + * Field evidence: photos, videos, signatures. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum EvidenceType { + PHOTO = 'PHOTO', + VIDEO = 'VIDEO', + SIGNATURE = 'SIGNATURE', + DOCUMENT = 'DOCUMENT', +} + +export enum SignerRole { + CUSTOMER = 'CUSTOMER', + TECHNICIAN = 'TECHNICIAN', + SUPERVISOR = 'SUPERVISOR', +} + +@Entity({ name: 'field_evidence', schema: 'field_service' }) +@Index('idx_evidence_tenant', ['tenantId']) +@Index('idx_evidence_incident', ['tenantId', 'incidentId']) +@Index('idx_evidence_type', ['tenantId', 'evidenceType']) +export class FieldEvidence { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ name: 'service_order_id', type: 'uuid', nullable: true }) + serviceOrderId?: string; + + @Column({ name: 'checklist_response_id', type: 'uuid', nullable: true }) + checklistResponseId?: string; + + @Column({ name: 'diagnosis_record_id', type: 'uuid', nullable: true }) + diagnosisRecordId?: string; + + @Column({ + name: 'evidence_type', + type: 'varchar', + length: 20, + }) + evidenceType: EvidenceType; + + // File info + @Column({ name: 'file_url', type: 'varchar', length: 500 }) + fileUrl: string; + + @Column({ name: 'file_name', type: 'varchar', length: 200, nullable: true }) + fileName?: string; + + @Column({ name: 'file_size', type: 'integer', nullable: true }) + fileSize?: number; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType?: string; + + // Metadata + @Column({ type: 'text', nullable: true }) + caption?: string; + + @Column({ type: 'jsonb', nullable: true }) + tags?: string[]; + + // Photo specific + @Column({ name: 'is_before', type: 'boolean', nullable: true }) + isBefore?: boolean; + + @Column({ name: 'is_after', type: 'boolean', nullable: true }) + isAfter?: boolean; + + // Signature specific + @Column({ name: 'signer_name', type: 'varchar', length: 150, nullable: true }) + signerName?: string; + + @Column({ name: 'signer_role', type: 'varchar', length: 50, nullable: true }) + signerRole?: SignerRole; + + // Location + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + latitude?: number; + + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + longitude?: number; + + // Offline + @Column({ name: 'is_offline', type: 'boolean', default: false }) + isOffline: boolean; + + @Column({ name: 'device_id', type: 'varchar', length: 100, nullable: true }) + deviceId?: string; + + @Column({ name: 'local_id', type: 'varchar', length: 100, nullable: true }) + localId?: string; + + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + @Column({ name: 'pending_upload', type: 'boolean', default: false }) + pendingUpload: boolean; + + // Audit + @Column({ name: 'captured_by', type: 'uuid', nullable: true }) + capturedBy?: string; + + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/field-service/entities/index.ts b/src/modules/field-service/entities/index.ts new file mode 100644 index 0000000..94edb9f --- /dev/null +++ b/src/modules/field-service/entities/index.ts @@ -0,0 +1,16 @@ +/** + * Field Service Entities Index + * Module: MMD-012 Field Service + */ + +export * from './service-checklist.entity'; +export * from './checklist-item-template.entity'; +export * from './checklist-response.entity'; +export * from './checklist-item-response.entity'; +export * from './work-log.entity'; +export * from './diagnosis-record.entity'; +export * from './activity-catalog.entity'; +export * from './root-cause-catalog.entity'; +export * from './field-evidence.entity'; +export * from './field-checkin.entity'; +export * from './offline-queue-item.entity'; diff --git a/src/modules/field-service/entities/offline-queue-item.entity.ts b/src/modules/field-service/entities/offline-queue-item.entity.ts new file mode 100644 index 0000000..9b52b0e --- /dev/null +++ b/src/modules/field-service/entities/offline-queue-item.entity.ts @@ -0,0 +1,115 @@ +/** + * OfflineQueueItem Entity + * Mecanicas Diesel - ERP Suite + * + * Offline sync queue for field data. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum SyncStatus { + PENDING = 'PENDING', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CONFLICT = 'CONFLICT', +} + +export enum SyncResolution { + LOCAL = 'LOCAL', + SERVER = 'SERVER', + MERGED = 'MERGED', +} + +@Entity({ name: 'offline_queue_items', schema: 'field_service' }) +@Index('idx_offline_queue_device', ['deviceId']) +@Index('idx_offline_queue_status', ['status']) +@Index('idx_offline_queue_entity', ['entityType', 'entityId']) +export class OfflineQueueItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'device_id', type: 'varchar', length: 100 }) + deviceId: string; + + @Column({ name: 'technician_id', type: 'uuid', nullable: true }) + technicianId?: string; + + // Entity reference + @Column({ name: 'entity_type', type: 'varchar', length: 50 }) + entityType: string; + + @Column({ name: 'entity_id', type: 'varchar', length: 100 }) + entityId: string; + + @Column({ name: 'local_id', type: 'varchar', length: 100 }) + localId: string; + + // Payload + @Column({ type: 'jsonb' }) + payload: Record; + + // Sync status + @Column({ + type: 'varchar', + length: 20, + default: SyncStatus.PENDING, + }) + status: SyncStatus; + + @Column({ type: 'integer', default: 0 }) + priority: number; + + // Attempts + @Column({ name: 'sync_attempts', type: 'integer', default: 0 }) + syncAttempts: number; + + @Column({ name: 'max_attempts', type: 'integer', default: 5 }) + maxAttempts: number; + + @Column({ name: 'last_attempt_at', type: 'timestamptz', nullable: true }) + lastAttemptAt?: Date; + + @Column({ name: 'next_attempt_at', type: 'timestamptz', nullable: true }) + nextAttemptAt?: Date; + + // Result + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + @Column({ name: 'server_entity_id', type: 'uuid', nullable: true }) + serverEntityId?: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode?: string; + + // Conflict resolution + @Column({ name: 'has_conflict', type: 'boolean', default: false }) + hasConflict: boolean; + + @Column({ name: 'conflict_data', type: 'jsonb', nullable: true }) + conflictData?: Record; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + @Column({ type: 'varchar', length: 20, nullable: true }) + resolution?: SyncResolution; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/entities/root-cause-catalog.entity.ts b/src/modules/field-service/entities/root-cause-catalog.entity.ts new file mode 100644 index 0000000..d215f14 --- /dev/null +++ b/src/modules/field-service/entities/root-cause-catalog.entity.ts @@ -0,0 +1,60 @@ +/** + * RootCauseCatalog Entity + * Mecanicas Diesel - ERP Suite + * + * Catalog of root causes for diagnostics. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'root_cause_catalog', schema: 'field_service' }) +@Index('idx_root_cause_tenant', ['tenantId']) +@Index('idx_root_cause_category', ['tenantId', 'category']) +export class RootCauseCatalog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 100 }) + category: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + subcategory?: string; + + @Column({ name: 'vehicle_types', type: 'jsonb', nullable: true }) + vehicleTypes?: string[]; + + @Column({ name: 'standard_recommendation', type: 'text', nullable: true }) + standardRecommendation?: string; + + @Column({ name: 'estimated_repair_hours', type: 'decimal', precision: 5, scale: 2, nullable: true }) + estimatedRepairHours?: 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; +} diff --git a/src/modules/field-service/entities/service-checklist.entity.ts b/src/modules/field-service/entities/service-checklist.entity.ts new file mode 100644 index 0000000..ade1b67 --- /dev/null +++ b/src/modules/field-service/entities/service-checklist.entity.ts @@ -0,0 +1,68 @@ +/** + * ServiceChecklist Entity + * Mecanicas Diesel - ERP Suite + * + * Template checklists for field service operations. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ChecklistItemTemplate } from './checklist-item-template.entity'; + +@Entity({ name: 'service_checklists', schema: 'field_service' }) +@Index('idx_checklists_tenant', ['tenantId']) +@Index('idx_checklists_service_type', ['tenantId', 'serviceTypeCode']) +export class ServiceChecklist { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 150 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + code?: string; + + @Column({ name: 'service_type_code', type: 'varchar', length: 50, nullable: true }) + serviceTypeCode?: string; + + @Column({ name: 'vehicle_type', type: 'varchar', length: 50, nullable: true }) + vehicleType?: string; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'integer', default: 1 }) + version: number; + + @OneToMany(() => ChecklistItemTemplate, (item) => item.checklist) + items: ChecklistItemTemplate[]; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/entities/work-log.entity.ts b/src/modules/field-service/entities/work-log.entity.ts new file mode 100644 index 0000000..d51ee27 --- /dev/null +++ b/src/modules/field-service/entities/work-log.entity.ts @@ -0,0 +1,125 @@ +/** + * WorkLog Entity + * Mecanicas Diesel - ERP Suite + * + * Labor timer records for field service. + * Module: MMD-012 Field Service + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum WorkLogStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +@Entity({ name: 'work_logs', schema: 'field_service' }) +@Index('idx_worklogs_tenant', ['tenantId']) +@Index('idx_worklogs_incident', ['tenantId', 'incidentId']) +@Index('idx_worklogs_technician', ['tenantId', 'technicianId']) +@Index('idx_worklogs_date', ['tenantId', 'startTime']) +export class WorkLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; + + @Column({ name: 'service_order_id', type: 'uuid', nullable: true }) + serviceOrderId?: string; + + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + // Activity + @Column({ name: 'activity_code', type: 'varchar', length: 50 }) + activityCode: string; + + @Column({ name: 'activity_name', type: 'varchar', length: 200, nullable: true }) + activityName?: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Timing + @Column({ name: 'start_time', type: 'timestamptz' }) + startTime: Date; + + @Column({ name: 'end_time', type: 'timestamptz', nullable: true }) + endTime?: Date; + + @Column({ name: 'duration_minutes', type: 'integer', nullable: true }) + durationMinutes?: number; + + // Pause tracking + @Column({ name: 'pause_start', type: 'timestamptz', nullable: true }) + pauseStart?: Date; + + @Column({ name: 'total_pause_minutes', type: 'integer', default: 0 }) + totalPauseMinutes: number; + + // Status + @Column({ + type: 'varchar', + length: 20, + default: WorkLogStatus.ACTIVE, + }) + status: WorkLogStatus; + + // Labor calculation + @Column({ name: 'labor_rate', type: 'decimal', precision: 10, scale: 2, nullable: true }) + laborRate?: number; + + @Column({ name: 'labor_total', type: 'decimal', precision: 12, scale: 2, nullable: true }) + laborTotal?: number; + + @Column({ name: 'is_overtime', type: 'boolean', default: false }) + isOvertime: boolean; + + @Column({ name: 'overtime_multiplier', type: 'decimal', precision: 3, scale: 2, default: 1.5 }) + overtimeMultiplier: number; + + // Location + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + latitude?: number; + + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + longitude?: number; + + // Offline + @Column({ name: 'is_offline', type: 'boolean', default: false }) + isOffline: boolean; + + @Column({ name: 'device_id', type: 'varchar', length: 100, nullable: true }) + deviceId?: string; + + @Column({ name: 'local_id', type: 'varchar', length: 100, nullable: true }) + localId?: string; + + @Column({ name: 'synced_at', type: 'timestamptz', nullable: true }) + syncedAt?: Date; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/field-service/index.ts b/src/modules/field-service/index.ts new file mode 100644 index 0000000..4bb6037 --- /dev/null +++ b/src/modules/field-service/index.ts @@ -0,0 +1,10 @@ +/** + * Field Service Module Index + * Module: MMD-012 Field Service + * + * Exports all entities, services, and controllers for the Field Service module. + */ + +export * from './entities'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/field-service/services/checkin.service.ts b/src/modules/field-service/services/checkin.service.ts new file mode 100644 index 0000000..05a235a --- /dev/null +++ b/src/modules/field-service/services/checkin.service.ts @@ -0,0 +1,296 @@ +/** + * CheckinService + * Mecanicas Diesel - ERP Suite + * + * Service for managing field check-ins and check-outs. + * Module: MMD-012 Field Service + */ + +import { DataSource, Repository, IsNull, Between } from 'typeorm'; +import { FieldCheckin } from '../entities'; + +// DTOs +export interface CheckinDto { + technicianId: string; + incidentId?: string; + serviceOrderId?: string; + unitId?: string; + latitude?: number; + longitude?: number; + address?: string; + photoUrl?: string; + isOffline?: boolean; + deviceId?: string; +} + +export interface CheckoutDto { + latitude?: number; + longitude?: number; + photoUrl?: string; + notes?: string; +} + +export interface TechnicianLocationInfo { + technicianId: string; + isOnSite: boolean; + currentCheckin?: { + id: string; + incidentId?: string; + serviceOrderId?: string; + checkinTime: Date; + latitude?: number; + longitude?: number; + address?: string; + }; + todayStats: { + checkinsCount: number; + totalOnSiteMinutes: number; + }; +} + +export class CheckinService { + private checkinRepo: Repository; + + constructor(private dataSource: DataSource) { + this.checkinRepo = dataSource.getRepository(FieldCheckin); + } + + // ======================== + // CHECK-IN/OUT OPERATIONS + // ======================== + + async checkin(tenantId: string, dto: CheckinDto): Promise { + // Check for active checkin + const activeCheckin = await this.getActiveCheckin(tenantId, dto.technicianId); + if (activeCheckin) { + throw new Error('Technician already has an active check-in. Please check out first.'); + } + + const checkin = this.checkinRepo.create({ + tenantId, + technicianId: dto.technicianId, + incidentId: dto.incidentId, + serviceOrderId: dto.serviceOrderId, + unitId: dto.unitId, + checkinTime: new Date(), + checkinLatitude: dto.latitude, + checkinLongitude: dto.longitude, + checkinAddress: dto.address, + checkinPhotoUrl: dto.photoUrl, + isOffline: dto.isOffline || false, + deviceId: dto.deviceId, + }); + + return this.checkinRepo.save(checkin); + } + + async checkout(tenantId: string, checkinId: string, dto: CheckoutDto): Promise { + const checkin = await this.checkinRepo.findOne({ + where: { id: checkinId, tenantId }, + }); + + if (!checkin) { + throw new Error('Check-in not found'); + } + + if (checkin.checkoutTime) { + throw new Error('Already checked out'); + } + + checkin.checkoutTime = new Date(); + checkin.checkoutLatitude = dto.latitude; + checkin.checkoutLongitude = dto.longitude; + checkin.checkoutPhotoUrl = dto.photoUrl; + if (dto.notes) { + checkin.notes = dto.notes; + } + + // On-site minutes is calculated by trigger, but we can do it here too + checkin.onSiteMinutes = Math.round( + (checkin.checkoutTime.getTime() - checkin.checkinTime.getTime()) / 60000 + ); + + return this.checkinRepo.save(checkin); + } + + async forceCheckout( + tenantId: string, + technicianId: string, + reason: string + ): Promise { + const activeCheckin = await this.getActiveCheckin(tenantId, technicianId); + if (!activeCheckin) { + return null; + } + + activeCheckin.checkoutTime = new Date(); + activeCheckin.notes = `Force checkout: ${reason}`; + activeCheckin.onSiteMinutes = Math.round( + (activeCheckin.checkoutTime.getTime() - activeCheckin.checkinTime.getTime()) / 60000 + ); + + return this.checkinRepo.save(activeCheckin); + } + + // ======================== + // QUERIES + // ======================== + + async getById(tenantId: string, id: string): Promise { + return this.checkinRepo.findOne({ + where: { id, tenantId }, + }); + } + + async getActiveCheckin(tenantId: string, technicianId: string): Promise { + return this.checkinRepo.findOne({ + where: { + tenantId, + technicianId, + checkoutTime: IsNull(), + }, + order: { checkinTime: 'DESC' }, + }); + } + + async getCheckinsForIncident(tenantId: string, incidentId: string): Promise { + return this.checkinRepo.find({ + where: { tenantId, incidentId }, + order: { checkinTime: 'DESC' }, + }); + } + + async getTechnicianCheckins( + tenantId: string, + technicianId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.checkinRepo.find({ + where: { + tenantId, + technicianId, + checkinTime: Between(startDate, endDate), + }, + order: { checkinTime: 'DESC' }, + }); + } + + async getTechnicianLocationInfo( + tenantId: string, + technicianId: string + ): Promise { + const activeCheckin = await this.getActiveCheckin(tenantId, technicianId); + + // Get today's stats + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const todayCheckins = await this.checkinRepo.find({ + where: { + tenantId, + technicianId, + checkinTime: Between(today, tomorrow), + }, + }); + + const checkinsCount = todayCheckins.length; + const totalOnSiteMinutes = todayCheckins.reduce( + (sum, c) => sum + (c.onSiteMinutes || 0), + 0 + ); + + return { + technicianId, + isOnSite: !!activeCheckin, + currentCheckin: activeCheckin + ? { + id: activeCheckin.id, + incidentId: activeCheckin.incidentId, + serviceOrderId: activeCheckin.serviceOrderId, + checkinTime: activeCheckin.checkinTime, + latitude: activeCheckin.checkinLatitude + ? Number(activeCheckin.checkinLatitude) + : undefined, + longitude: activeCheckin.checkinLongitude + ? Number(activeCheckin.checkinLongitude) + : undefined, + address: activeCheckin.checkinAddress, + } + : undefined, + todayStats: { + checkinsCount, + totalOnSiteMinutes, + }, + }; + } + + async getActiveCheckinsForTenant(tenantId: string): Promise { + return this.checkinRepo.find({ + where: { + tenantId, + checkoutTime: IsNull(), + }, + order: { checkinTime: 'DESC' }, + }); + } + + // ======================== + // STATISTICS + // ======================== + + async getDailyStats( + tenantId: string, + date: Date + ): Promise<{ + totalCheckins: number; + totalCheckouts: number; + activeCheckins: number; + avgOnSiteMinutes: number; + byTechnician: { technicianId: string; checkins: number; totalMinutes: number }[]; + }> { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(startOfDay); + endOfDay.setDate(endOfDay.getDate() + 1); + + const checkins = await this.checkinRepo.find({ + where: { + tenantId, + checkinTime: Between(startOfDay, endOfDay), + }, + }); + + const totalCheckins = checkins.length; + const totalCheckouts = checkins.filter((c) => c.checkoutTime).length; + const activeCheckins = checkins.filter((c) => !c.checkoutTime).length; + const completedCheckins = checkins.filter((c) => c.onSiteMinutes); + const avgOnSiteMinutes = completedCheckins.length > 0 + ? completedCheckins.reduce((sum, c) => sum + (c.onSiteMinutes || 0), 0) / completedCheckins.length + : 0; + + // Group by technician + const byTechnicianMap = new Map(); + for (const c of checkins) { + const existing = byTechnicianMap.get(c.technicianId) || { checkins: 0, totalMinutes: 0 }; + existing.checkins++; + existing.totalMinutes += c.onSiteMinutes || 0; + byTechnicianMap.set(c.technicianId, existing); + } + + const byTechnician = Array.from(byTechnicianMap.entries()).map(([technicianId, data]) => ({ + technicianId, + ...data, + })); + + return { + totalCheckins, + totalCheckouts, + activeCheckins, + avgOnSiteMinutes, + byTechnician, + }; + } +} diff --git a/src/modules/field-service/services/checklist.service.ts b/src/modules/field-service/services/checklist.service.ts new file mode 100644 index 0000000..4d7ce6b --- /dev/null +++ b/src/modules/field-service/services/checklist.service.ts @@ -0,0 +1,360 @@ +/** + * ChecklistService + * Mecanicas Diesel - ERP Suite + * + * Service for managing checklists and responses. + * Module: MMD-012 Field Service + */ + +import { DataSource, Repository } from 'typeorm'; +import { + ServiceChecklist, + ChecklistItemTemplate, + ChecklistItemType, + ChecklistResponse, + ChecklistStatus, + ChecklistItemResponse, +} from '../entities'; + +// DTOs +export interface CreateChecklistDto { + name: string; + description?: string; + code?: string; + serviceTypeCode?: string; + vehicleType?: string; + isDefault?: boolean; + createdBy?: string; +} + +export interface CreateChecklistItemDto { + itemOrder: number; + text: string; + itemType: ChecklistItemType; + isRequired?: boolean; + options?: string[]; + unit?: string; + minValue?: number; + maxValue?: number; + helpText?: string; + photoRequired?: boolean; + section?: string; +} + +export interface StartChecklistDto { + checklistId: string; + technicianId: string; + incidentId?: string; + serviceOrderId?: string; + startLatitude?: number; + startLongitude?: number; + isOffline?: boolean; + deviceId?: string; + localId?: string; +} + +export interface SaveItemResponseDto { + itemTemplateId: string; + valueBoolean?: boolean; + valueText?: string; + valueNumber?: number; + valueJson?: any; + photoUrl?: string; + photoUrls?: string[]; + signatureUrl?: string; + isPassed?: boolean; + failureReason?: string; + localId?: string; +} + +export class ChecklistService { + private checklistRepo: Repository; + private itemTemplateRepo: Repository; + private responseRepo: Repository; + private itemResponseRepo: Repository; + + constructor(private dataSource: DataSource) { + this.checklistRepo = dataSource.getRepository(ServiceChecklist); + this.itemTemplateRepo = dataSource.getRepository(ChecklistItemTemplate); + this.responseRepo = dataSource.getRepository(ChecklistResponse); + this.itemResponseRepo = dataSource.getRepository(ChecklistItemResponse); + } + + // ======================== + // CHECKLIST TEMPLATE CRUD + // ======================== + + async createChecklist(tenantId: string, dto: CreateChecklistDto): Promise { + const checklist = this.checklistRepo.create({ + tenantId, + ...dto, + }); + return this.checklistRepo.save(checklist); + } + + async getChecklistById(tenantId: string, id: string): Promise { + return this.checklistRepo.findOne({ + where: { id, tenantId }, + relations: ['items'], + }); + } + + async getChecklistForServiceType( + tenantId: string, + serviceTypeCode: string + ): Promise { + return this.checklistRepo.findOne({ + where: { tenantId, serviceTypeCode, isActive: true }, + relations: ['items'], + order: { version: 'DESC' }, + }); + } + + async getDefaultChecklist(tenantId: string): Promise { + return this.checklistRepo.findOne({ + where: { tenantId, isDefault: true, isActive: true }, + relations: ['items'], + }); + } + + async findAllChecklists( + tenantId: string, + options?: { isActive?: boolean; serviceTypeCode?: string } + ): Promise { + const qb = this.checklistRepo.createQueryBuilder('c') + .where('c.tenant_id = :tenantId', { tenantId }) + .leftJoinAndSelect('c.items', 'items') + .orderBy('c.name', 'ASC'); + + if (options?.isActive !== undefined) { + qb.andWhere('c.is_active = :isActive', { isActive: options.isActive }); + } + if (options?.serviceTypeCode) { + qb.andWhere('c.service_type_code = :serviceTypeCode', { serviceTypeCode: options.serviceTypeCode }); + } + + return qb.getMany(); + } + + async updateChecklist( + tenantId: string, + id: string, + dto: Partial + ): Promise { + await this.checklistRepo.update({ id, tenantId }, dto); + return this.getChecklistById(tenantId, id); + } + + async deactivateChecklist(tenantId: string, id: string): Promise { + await this.checklistRepo.update({ id, tenantId }, { isActive: false }); + } + + // ======================== + // CHECKLIST ITEMS + // ======================== + + async addChecklistItem( + tenantId: string, + checklistId: string, + dto: CreateChecklistItemDto + ): Promise { + const item = this.itemTemplateRepo.create({ + tenantId, + checklistId, + ...dto, + }); + return this.itemTemplateRepo.save(item); + } + + async addChecklistItems( + tenantId: string, + checklistId: string, + items: CreateChecklistItemDto[] + ): Promise { + const entities = items.map((dto) => + this.itemTemplateRepo.create({ + tenantId, + checklistId, + ...dto, + }) + ); + return this.itemTemplateRepo.save(entities); + } + + async updateChecklistItem( + tenantId: string, + itemId: string, + dto: Partial + ): Promise { + await this.itemTemplateRepo.update({ id: itemId, tenantId }, dto); + } + + async deleteChecklistItem(tenantId: string, itemId: string): Promise { + await this.itemTemplateRepo.delete({ id: itemId, tenantId }); + } + + async reorderChecklistItems(tenantId: string, checklistId: string, itemOrder: { id: string; order: number }[]): Promise { + for (const item of itemOrder) { + await this.itemTemplateRepo.update( + { id: item.id, tenantId, checklistId }, + { itemOrder: item.order } + ); + } + } + + // ======================== + // CHECKLIST RESPONSES + // ======================== + + async startChecklist(tenantId: string, dto: StartChecklistDto): Promise { + const checklist = await this.getChecklistById(tenantId, dto.checklistId); + if (!checklist) { + throw new Error('Checklist not found'); + } + + const response = this.responseRepo.create({ + tenantId, + checklistId: dto.checklistId, + technicianId: dto.technicianId, + incidentId: dto.incidentId, + serviceOrderId: dto.serviceOrderId, + status: ChecklistStatus.STARTED, + startLatitude: dto.startLatitude, + startLongitude: dto.startLongitude, + totalItems: checklist.items?.length || 0, + isOffline: dto.isOffline || false, + deviceId: dto.deviceId, + localId: dto.localId, + }); + + return this.responseRepo.save(response); + } + + async getChecklistResponse(tenantId: string, responseId: string): Promise { + return this.responseRepo.findOne({ + where: { id: responseId, tenantId }, + relations: ['itemResponses'], + }); + } + + async getChecklistResponsesForIncident(tenantId: string, incidentId: string): Promise { + return this.responseRepo.find({ + where: { tenantId, incidentId }, + relations: ['itemResponses'], + order: { startedAt: 'DESC' }, + }); + } + + async saveItemResponse( + tenantId: string, + responseId: string, + dto: SaveItemResponseDto + ): Promise { + const existing = await this.itemResponseRepo.findOne({ + where: { + tenantId, + checklistResponseId: responseId, + itemTemplateId: dto.itemTemplateId, + }, + }); + + if (existing) { + await this.itemResponseRepo.update({ id: existing.id }, { + ...dto, + capturedAt: new Date(), + }); + return this.itemResponseRepo.findOneOrFail({ where: { id: existing.id } }); + } + + const itemResponse = this.itemResponseRepo.create({ + tenantId, + checklistResponseId: responseId, + ...dto, + }); + + const saved = await this.itemResponseRepo.save(itemResponse); + + // Update response status + await this.responseRepo.update( + { id: responseId, tenantId }, + { status: ChecklistStatus.IN_PROGRESS } + ); + + return saved; + } + + async completeChecklist( + tenantId: string, + responseId: string, + endLocation?: { latitude: number; longitude: number } + ): Promise { + await this.responseRepo.update( + { id: responseId, tenantId }, + { + status: ChecklistStatus.COMPLETED, + completedAt: new Date(), + endLatitude: endLocation?.latitude, + endLongitude: endLocation?.longitude, + } + ); + + return this.responseRepo.findOneOrFail({ + where: { id: responseId, tenantId }, + relations: ['itemResponses'], + }); + } + + async cancelChecklist(tenantId: string, responseId: string): Promise { + await this.responseRepo.update( + { id: responseId, tenantId }, + { status: ChecklistStatus.CANCELLED } + ); + } + + // ======================== + // STATISTICS + // ======================== + + async getChecklistStats(tenantId: string, checklistId: string): Promise<{ + totalResponses: number; + completedCount: number; + avgCompletionRate: number; + avgCompletionMinutes: number; + }> { + const result = await this.responseRepo + .createQueryBuilder('r') + .select('COUNT(*)', 'totalResponses') + .addSelect('COUNT(*) FILTER (WHERE r.status = :completed)', 'completedCount') + .addSelect( + 'AVG(r.completed_items::FLOAT / NULLIF(r.total_items, 0) * 100)', + 'avgCompletionRate' + ) + .addSelect( + 'AVG(EXTRACT(EPOCH FROM (r.completed_at - r.started_at)) / 60) FILTER (WHERE r.status = :completed)', + 'avgCompletionMinutes' + ) + .where('r.tenant_id = :tenantId', { tenantId }) + .andWhere('r.checklist_id = :checklistId', { checklistId }) + .setParameter('completed', ChecklistStatus.COMPLETED) + .getRawOne(); + + return { + totalResponses: parseInt(result.totalResponses) || 0, + completedCount: parseInt(result.completedCount) || 0, + avgCompletionRate: parseFloat(result.avgCompletionRate) || 0, + avgCompletionMinutes: parseFloat(result.avgCompletionMinutes) || 0, + }; + } + + async getTechnicianChecklistHistory( + tenantId: string, + technicianId: string, + limit = 20 + ): Promise { + return this.responseRepo.find({ + where: { tenantId, technicianId }, + order: { startedAt: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/modules/field-service/services/diagnosis.service.ts b/src/modules/field-service/services/diagnosis.service.ts new file mode 100644 index 0000000..62209c4 --- /dev/null +++ b/src/modules/field-service/services/diagnosis.service.ts @@ -0,0 +1,351 @@ +/** + * DiagnosisService + * Mecanicas Diesel - ERP Suite + * + * Service for managing diagnoses and root causes. + * Module: MMD-012 Field Service + */ + +import { DataSource, Repository } from 'typeorm'; +import { + DiagnosisRecord, + DiagnosisType, + DiagnosisSeverity, + RootCauseCatalog, +} from '../entities'; + +// DTOs +export interface CreateDiagnosisDto { + technicianId: string; + incidentId?: string; + serviceOrderId?: string; + vehicleId?: string; + diagnosisType: DiagnosisType; + symptoms: string; + customerComplaint?: string; + rootCauseCode?: string; + rootCauseCategory?: string; + rootCauseDescription?: string; + obd2Codes?: string[]; + obd2RawData?: Record; + odometerReading?: number; + engineHours?: number; + fuelLevel?: number; + recommendation?: string; + severity?: DiagnosisSeverity; + requiresImmediateAction?: boolean; + photoUrls?: string[]; + videoUrls?: string[]; + latitude?: number; + longitude?: number; + isOffline?: boolean; + deviceId?: string; + localId?: string; +} + +export interface Obd2CodeInfo { + code: string; + description: string; + category: string; + severity: string; + possibleCauses: string[]; +} + +export interface CreateRootCauseDto { + code: string; + name: string; + description?: string; + category: string; + subcategory?: string; + vehicleTypes?: string[]; + standardRecommendation?: string; + estimatedRepairHours?: number; +} + +// Common OBD-II codes database (simplified) +const OBD2_CODES_DB: Record = { + P0300: { + code: 'P0300', + description: 'Random/Multiple Cylinder Misfire Detected', + category: 'Powertrain', + severity: 'MODERATE', + possibleCauses: ['Spark plugs worn', 'Ignition coil failure', 'Fuel injector issue', 'Vacuum leak'], + }, + P0171: { + code: 'P0171', + description: 'System Too Lean (Bank 1)', + category: 'Fuel/Air', + severity: 'MODERATE', + possibleCauses: ['Vacuum leak', 'Faulty MAF sensor', 'Fuel pump weak', 'Clogged fuel filter'], + }, + P0420: { + code: 'P0420', + description: 'Catalyst System Efficiency Below Threshold (Bank 1)', + category: 'Emissions', + severity: 'LOW', + possibleCauses: ['Catalytic converter failing', 'O2 sensor faulty', 'Exhaust leak'], + }, + P0401: { + code: 'P0401', + description: 'Exhaust Gas Recirculation Flow Insufficient', + category: 'Emissions', + severity: 'LOW', + possibleCauses: ['EGR valve stuck', 'Carbon buildup', 'EGR passage blocked'], + }, + P0500: { + code: 'P0500', + description: 'Vehicle Speed Sensor Malfunction', + category: 'Transmission', + severity: 'MODERATE', + possibleCauses: ['VSS faulty', 'Wiring issue', 'Connector problem'], + }, +}; + +export class DiagnosisService { + private diagnosisRepo: Repository; + private rootCauseRepo: Repository; + + constructor(private dataSource: DataSource) { + this.diagnosisRepo = dataSource.getRepository(DiagnosisRecord); + this.rootCauseRepo = dataSource.getRepository(RootCauseCatalog); + } + + // ======================== + // DIAGNOSIS RECORDS + // ======================== + + async createDiagnosis(tenantId: string, dto: CreateDiagnosisDto): Promise { + const diagnosis = this.diagnosisRepo.create({ + tenantId, + ...dto, + }); + return this.diagnosisRepo.save(diagnosis); + } + + async getById(tenantId: string, id: string): Promise { + return this.diagnosisRepo.findOne({ + where: { id, tenantId }, + }); + } + + async getDiagnosesForIncident(tenantId: string, incidentId: string): Promise { + return this.diagnosisRepo.find({ + where: { tenantId, incidentId }, + order: { createdAt: 'DESC' }, + }); + } + + async getDiagnosesForVehicle(tenantId: string, vehicleId: string): Promise { + return this.diagnosisRepo.find({ + where: { tenantId, vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + async updateDiagnosis( + tenantId: string, + id: string, + dto: Partial + ): Promise { + await this.diagnosisRepo.update({ id, tenantId }, dto); + return this.getById(tenantId, id); + } + + async deleteDiagnosis(tenantId: string, id: string): Promise { + await this.diagnosisRepo.delete({ id, tenantId }); + } + + async findDiagnoses( + tenantId: string, + options?: { + technicianId?: string; + diagnosisType?: DiagnosisType; + severity?: DiagnosisSeverity; + startDate?: Date; + endDate?: Date; + limit?: number; + } + ): Promise { + const qb = this.diagnosisRepo.createQueryBuilder('d') + .where('d.tenant_id = :tenantId', { tenantId }) + .orderBy('d.created_at', 'DESC'); + + if (options?.technicianId) { + qb.andWhere('d.technician_id = :technicianId', { technicianId: options.technicianId }); + } + if (options?.diagnosisType) { + qb.andWhere('d.diagnosis_type = :diagnosisType', { diagnosisType: options.diagnosisType }); + } + if (options?.severity) { + qb.andWhere('d.severity = :severity', { severity: options.severity }); + } + if (options?.startDate) { + qb.andWhere('d.created_at >= :startDate', { startDate: options.startDate }); + } + if (options?.endDate) { + qb.andWhere('d.created_at <= :endDate', { endDate: options.endDate }); + } + if (options?.limit) { + qb.take(options.limit); + } + + return qb.getMany(); + } + + // ======================== + // OBD-II CODES + // ======================== + + async parseObd2Codes(codes: string[]): Promise { + return codes.map((code) => { + const upperCode = code.toUpperCase(); + if (OBD2_CODES_DB[upperCode]) { + return OBD2_CODES_DB[upperCode]; + } + + // Parse unknown codes + const category = this.getObd2Category(upperCode); + return { + code: upperCode, + description: 'Unknown code - requires manual lookup', + category, + severity: 'UNKNOWN', + possibleCauses: [], + }; + }); + } + + private getObd2Category(code: string): string { + const prefix = code.charAt(0).toUpperCase(); + switch (prefix) { + case 'P': + return 'Powertrain'; + case 'B': + return 'Body'; + case 'C': + return 'Chassis'; + case 'U': + return 'Network'; + default: + return 'Unknown'; + } + } + + async getCriticalDiagnoses(tenantId: string): Promise { + return this.diagnosisRepo.find({ + where: [ + { tenantId, severity: DiagnosisSeverity.CRITICAL }, + { tenantId, requiresImmediateAction: true }, + ], + order: { createdAt: 'DESC' }, + }); + } + + // ======================== + // ROOT CAUSE CATALOG + // ======================== + + async createRootCause(tenantId: string, dto: CreateRootCauseDto): Promise { + const rootCause = this.rootCauseRepo.create({ + tenantId, + ...dto, + }); + return this.rootCauseRepo.save(rootCause); + } + + async getRootCauseCatalog( + tenantId: string, + options?: { category?: string; isActive?: boolean } + ): Promise { + const qb = this.rootCauseRepo.createQueryBuilder('rc') + .where('rc.tenant_id = :tenantId', { tenantId }) + .orderBy('rc.category', 'ASC') + .addOrderBy('rc.name', 'ASC'); + + if (options?.category) { + qb.andWhere('rc.category = :category', { category: options.category }); + } + if (options?.isActive !== undefined) { + qb.andWhere('rc.is_active = :isActive', { isActive: options.isActive }); + } + + return qb.getMany(); + } + + async getRootCauseByCode(tenantId: string, code: string): Promise { + return this.rootCauseRepo.findOne({ + where: { tenantId, code }, + }); + } + + async getRootCauseCategories(tenantId: string): Promise { + const result = await this.rootCauseRepo + .createQueryBuilder('rc') + .select('DISTINCT rc.category', 'category') + .where('rc.tenant_id = :tenantId', { tenantId }) + .andWhere('rc.is_active = true') + .orderBy('rc.category', 'ASC') + .getRawMany(); + + return result.map((r) => r.category); + } + + async updateRootCause( + tenantId: string, + id: string, + dto: Partial + ): Promise { + await this.rootCauseRepo.update({ id, tenantId }, dto); + } + + async deactivateRootCause(tenantId: string, id: string): Promise { + await this.rootCauseRepo.update({ id, tenantId }, { isActive: false }); + } + + // ======================== + // STATISTICS + // ======================== + + async getDiagnosisStats( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ + total: number; + byType: Record; + bySeverity: Record; + topRootCauses: { code: string; count: number }[]; + }> { + const diagnoses = await this.diagnosisRepo + .createQueryBuilder('d') + .where('d.tenant_id = :tenantId', { tenantId }) + .andWhere('d.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const byType: Record = {}; + const bySeverity: Record = {}; + const rootCauseCount: Record = {}; + + for (const d of diagnoses) { + byType[d.diagnosisType] = (byType[d.diagnosisType] || 0) + 1; + if (d.severity) { + bySeverity[d.severity] = (bySeverity[d.severity] || 0) + 1; + } + if (d.rootCauseCode) { + rootCauseCount[d.rootCauseCode] = (rootCauseCount[d.rootCauseCode] || 0) + 1; + } + } + + const topRootCauses = Object.entries(rootCauseCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([code, count]) => ({ code, count })); + + return { + total: diagnoses.length, + byType, + bySeverity, + topRootCauses, + }; + } +} diff --git a/src/modules/field-service/services/index.ts b/src/modules/field-service/services/index.ts new file mode 100644 index 0000000..eaeb4b9 --- /dev/null +++ b/src/modules/field-service/services/index.ts @@ -0,0 +1,10 @@ +/** + * Field Service Services Index + * Module: MMD-012 Field Service + */ + +export * from './checklist.service'; +export * from './worklog.service'; +export * from './diagnosis.service'; +export * from './offline-sync.service'; +export * from './checkin.service'; diff --git a/src/modules/field-service/services/offline-sync.service.ts b/src/modules/field-service/services/offline-sync.service.ts new file mode 100644 index 0000000..711c5a2 --- /dev/null +++ b/src/modules/field-service/services/offline-sync.service.ts @@ -0,0 +1,388 @@ +/** + * OfflineSyncService + * Mecanicas Diesel - ERP Suite + * + * Service for managing offline data synchronization. + * Module: MMD-012 Field Service + */ + +import { DataSource, Repository, LessThan, In } from 'typeorm'; +import { + OfflineQueueItem, + SyncStatus, + SyncResolution, + ChecklistResponse, + WorkLog, + DiagnosisRecord, + FieldEvidence, + FieldCheckin, +} from '../entities'; + +// DTOs +export interface QueueItemDto { + deviceId: string; + technicianId?: string; + entityType: string; + entityId: string; + localId: string; + payload: Record; + priority?: number; +} + +export interface SyncResult { + success: number; + failed: number; + conflicts: number; + results: { + localId: string; + status: 'success' | 'failed' | 'conflict'; + serverEntityId?: string; + error?: string; + }[]; +} + +export interface ConflictInfo { + queueItemId: string; + localId: string; + entityType: string; + localData: Record; + serverData: Record; + conflictFields: string[]; +} + +export class OfflineSyncService { + private queueRepo: Repository; + private checklistResponseRepo: Repository; + private workLogRepo: Repository; + private diagnosisRepo: Repository; + private evidenceRepo: Repository; + private checkinRepo: Repository; + + constructor(private dataSource: DataSource) { + this.queueRepo = dataSource.getRepository(OfflineQueueItem); + this.checklistResponseRepo = dataSource.getRepository(ChecklistResponse); + this.workLogRepo = dataSource.getRepository(WorkLog); + this.diagnosisRepo = dataSource.getRepository(DiagnosisRecord); + this.evidenceRepo = dataSource.getRepository(FieldEvidence); + this.checkinRepo = dataSource.getRepository(FieldCheckin); + } + + // ======================== + // QUEUE MANAGEMENT + // ======================== + + async queueForSync(dto: QueueItemDto): Promise { + const queueItem = this.queueRepo.create({ + deviceId: dto.deviceId, + technicianId: dto.technicianId, + entityType: dto.entityType, + entityId: dto.entityId, + localId: dto.localId, + payload: dto.payload, + priority: dto.priority || 0, + status: SyncStatus.PENDING, + nextAttemptAt: new Date(), + }); + + return this.queueRepo.save(queueItem); + } + + async getQueuedItems(deviceId: string): Promise { + return this.queueRepo.find({ + where: { + deviceId, + status: In([SyncStatus.PENDING, SyncStatus.FAILED]), + }, + order: { priority: 'DESC', createdAt: 'ASC' }, + }); + } + + async getPendingCount(deviceId: string): Promise { + return this.queueRepo.count({ + where: { + deviceId, + status: In([SyncStatus.PENDING, SyncStatus.FAILED]), + }, + }); + } + + // ======================== + // SYNC PROCESSING + // ======================== + + async processQueue(deviceId: string, tenantId: string): Promise { + const items = await this.getQueuedItems(deviceId); + + const result: SyncResult = { + success: 0, + failed: 0, + conflicts: 0, + results: [], + }; + + for (const item of items) { + try { + // Mark as in progress + await this.queueRepo.update({ id: item.id }, { + status: SyncStatus.IN_PROGRESS, + lastAttemptAt: new Date(), + syncAttempts: item.syncAttempts + 1, + }); + + // Process based on entity type + const serverEntityId = await this.processSyncItem(tenantId, item); + + // Mark as completed + await this.queueRepo.update({ id: item.id }, { + status: SyncStatus.COMPLETED, + syncedAt: new Date(), + serverEntityId, + }); + + result.success++; + result.results.push({ + localId: item.localId, + status: 'success', + serverEntityId, + }); + } catch (error: any) { + // Check if it's a conflict + if (error.message?.includes('CONFLICT')) { + await this.queueRepo.update({ id: item.id }, { + status: SyncStatus.CONFLICT, + hasConflict: true, + errorMessage: error.message, + }); + result.conflicts++; + result.results.push({ + localId: item.localId, + status: 'conflict', + error: error.message, + }); + } else { + // Regular failure + const nextAttempt = new Date(); + nextAttempt.setMinutes(nextAttempt.getMinutes() + Math.pow(2, item.syncAttempts)); // Exponential backoff + + await this.queueRepo.update({ id: item.id }, { + status: item.syncAttempts >= item.maxAttempts ? SyncStatus.FAILED : SyncStatus.PENDING, + errorMessage: error.message, + nextAttemptAt: nextAttempt, + }); + + result.failed++; + result.results.push({ + localId: item.localId, + status: 'failed', + error: error.message, + }); + } + } + } + + return result; + } + + private async processSyncItem(tenantId: string, item: OfflineQueueItem): Promise { + const payload = item.payload; + + switch (item.entityType) { + case 'ChecklistResponse': + return this.syncChecklistResponse(tenantId, payload); + case 'WorkLog': + return this.syncWorkLog(tenantId, payload); + case 'DiagnosisRecord': + return this.syncDiagnosisRecord(tenantId, payload); + case 'FieldEvidence': + return this.syncFieldEvidence(tenantId, payload); + case 'FieldCheckin': + return this.syncFieldCheckin(tenantId, payload); + default: + throw new Error(`Unknown entity type: ${item.entityType}`); + } + } + + private async syncChecklistResponse(tenantId: string, payload: Record): Promise { + const response = this.checklistResponseRepo.create({ + ...payload, + tenantId, + syncedAt: new Date(), + }); + const saved = await this.checklistResponseRepo.save(response); + return saved.id; + } + + private async syncWorkLog(tenantId: string, payload: Record): Promise { + const workLog = this.workLogRepo.create({ + ...payload, + tenantId, + syncedAt: new Date(), + }); + const saved = await this.workLogRepo.save(workLog); + return saved.id; + } + + private async syncDiagnosisRecord(tenantId: string, payload: Record): Promise { + const diagnosis = this.diagnosisRepo.create({ + ...payload, + tenantId, + syncedAt: new Date(), + }); + const saved = await this.diagnosisRepo.save(diagnosis); + return saved.id; + } + + private async syncFieldEvidence(tenantId: string, payload: Record): Promise { + const evidence = this.evidenceRepo.create({ + ...payload, + tenantId, + syncedAt: new Date(), + }); + const saved = await this.evidenceRepo.save(evidence); + return saved.id; + } + + private async syncFieldCheckin(tenantId: string, payload: Record): Promise { + const checkin = this.checkinRepo.create({ + ...payload, + tenantId, + syncedAt: new Date(), + }); + const saved = await this.checkinRepo.save(checkin); + return saved.id; + } + + // ======================== + // CONFLICT RESOLUTION + // ======================== + + async getConflicts(deviceId: string): Promise { + const conflicts = await this.queueRepo.find({ + where: { deviceId, status: SyncStatus.CONFLICT }, + }); + + return conflicts.map((c) => ({ + queueItemId: c.id, + localId: c.localId, + entityType: c.entityType, + localData: c.payload, + serverData: c.conflictData || {}, + conflictFields: [], // Would need to calculate diff + })); + } + + async resolveConflict( + itemId: string, + resolution: SyncResolution, + mergedData?: Record + ): Promise { + const item = await this.queueRepo.findOne({ where: { id: itemId } }); + if (!item) { + throw new Error('Queue item not found'); + } + + if (resolution === SyncResolution.LOCAL) { + // Re-queue with local data + await this.queueRepo.update({ id: itemId }, { + status: SyncStatus.PENDING, + hasConflict: false, + resolvedAt: new Date(), + resolution, + syncAttempts: 0, + nextAttemptAt: new Date(), + }); + } else if (resolution === SyncResolution.SERVER) { + // Discard local, mark as completed + await this.queueRepo.update({ id: itemId }, { + status: SyncStatus.COMPLETED, + hasConflict: false, + resolvedAt: new Date(), + resolution, + }); + } else if (resolution === SyncResolution.MERGED && mergedData) { + // Use merged data + await this.queueRepo.update({ id: itemId }, { + payload: mergedData, + status: SyncStatus.PENDING, + hasConflict: false, + resolvedAt: new Date(), + resolution, + syncAttempts: 0, + nextAttemptAt: new Date(), + }); + } + } + + // ======================== + // CLEANUP + // ======================== + + async cleanupCompletedItems(olderThanDays = 7): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + const result = await this.queueRepo.delete({ + status: SyncStatus.COMPLETED, + syncedAt: LessThan(cutoffDate), + }); + + return result.affected || 0; + } + + async retryFailedItems(deviceId: string): Promise { + const result = await this.queueRepo.update( + { + deviceId, + status: SyncStatus.FAILED, + }, + { + status: SyncStatus.PENDING, + syncAttempts: 0, + nextAttemptAt: new Date(), + errorMessage: undefined, + } + ); + + return result.affected || 0; + } + + // ======================== + // STATISTICS + // ======================== + + async getSyncStats(deviceId: string): Promise<{ + pending: number; + inProgress: number; + completed: number; + failed: number; + conflicts: number; + lastSyncAt?: Date; + }> { + const counts = await this.queueRepo + .createQueryBuilder('q') + .select('q.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('q.device_id = :deviceId', { deviceId }) + .groupBy('q.status') + .getRawMany(); + + const lastCompleted = await this.queueRepo.findOne({ + where: { deviceId, status: SyncStatus.COMPLETED }, + order: { syncedAt: 'DESC' }, + }); + + const stats: Record = {}; + counts.forEach((c) => { + stats[c.status] = parseInt(c.count); + }); + + return { + pending: stats[SyncStatus.PENDING] || 0, + inProgress: stats[SyncStatus.IN_PROGRESS] || 0, + completed: stats[SyncStatus.COMPLETED] || 0, + failed: stats[SyncStatus.FAILED] || 0, + conflicts: stats[SyncStatus.CONFLICT] || 0, + lastSyncAt: lastCompleted?.syncedAt, + }; + } +} diff --git a/src/modules/field-service/services/worklog.service.ts b/src/modules/field-service/services/worklog.service.ts new file mode 100644 index 0000000..70a628e --- /dev/null +++ b/src/modules/field-service/services/worklog.service.ts @@ -0,0 +1,359 @@ +/** + * WorkLogService + * Mecanicas Diesel - ERP Suite + * + * Service for managing labor time tracking. + * Module: MMD-012 Field Service + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { WorkLog, WorkLogStatus, ActivityCatalog } from '../entities'; + +// DTOs +export interface StartWorkDto { + technicianId: string; + activityCode: string; + activityName?: string; + incidentId?: string; + serviceOrderId?: string; + description?: string; + laborRate?: number; + isOvertime?: boolean; + latitude?: number; + longitude?: number; + isOffline?: boolean; + deviceId?: string; + localId?: string; +} + +export interface LaborSummary { + totalMinutes: number; + totalLabor: number; + regularMinutes: number; + regularLabor: number; + overtimeMinutes: number; + overtimeLabor: number; + activities: { + activityCode: string; + activityName: string; + minutes: number; + labor: number; + }[]; +} + +export interface CreateActivityDto { + code: string; + name: string; + description?: string; + category?: string; + serviceTypeCode?: string; + defaultDurationMinutes?: number; + defaultRate?: number; +} + +export class WorkLogService { + private workLogRepo: Repository; + private activityRepo: Repository; + + constructor(private dataSource: DataSource) { + this.workLogRepo = dataSource.getRepository(WorkLog); + this.activityRepo = dataSource.getRepository(ActivityCatalog); + } + + // ======================== + // WORK LOG OPERATIONS + // ======================== + + async startWork(tenantId: string, dto: StartWorkDto): Promise { + // Check for active work for this technician + const activeWork = await this.workLogRepo.findOne({ + where: { + tenantId, + technicianId: dto.technicianId, + status: WorkLogStatus.ACTIVE, + }, + }); + + if (activeWork) { + throw new Error('Technician already has active work. Please stop it first.'); + } + + // Get default rate from activity catalog if not provided + let laborRate = dto.laborRate; + if (!laborRate && dto.activityCode) { + const activity = await this.activityRepo.findOne({ + where: { tenantId, code: dto.activityCode }, + }); + laborRate = activity?.defaultRate ? Number(activity.defaultRate) : undefined; + } + + const workLog = this.workLogRepo.create({ + tenantId, + technicianId: dto.technicianId, + activityCode: dto.activityCode, + activityName: dto.activityName, + incidentId: dto.incidentId, + serviceOrderId: dto.serviceOrderId, + description: dto.description, + startTime: new Date(), + status: WorkLogStatus.ACTIVE, + laborRate, + isOvertime: dto.isOvertime || false, + latitude: dto.latitude, + longitude: dto.longitude, + isOffline: dto.isOffline || false, + deviceId: dto.deviceId, + localId: dto.localId, + }); + + return this.workLogRepo.save(workLog); + } + + async pauseWork(tenantId: string, workLogId: string): Promise { + const workLog = await this.workLogRepo.findOne({ + where: { id: workLogId, tenantId }, + }); + + if (!workLog) { + throw new Error('Work log not found'); + } + + if (workLog.status !== WorkLogStatus.ACTIVE) { + throw new Error('Work log is not active'); + } + + workLog.status = WorkLogStatus.PAUSED; + workLog.pauseStart = new Date(); + + return this.workLogRepo.save(workLog); + } + + async resumeWork(tenantId: string, workLogId: string): Promise { + const workLog = await this.workLogRepo.findOne({ + where: { id: workLogId, tenantId }, + }); + + if (!workLog) { + throw new Error('Work log not found'); + } + + if (workLog.status !== WorkLogStatus.PAUSED) { + throw new Error('Work log is not paused'); + } + + // Calculate pause time + if (workLog.pauseStart) { + const pauseMinutes = Math.round( + (Date.now() - workLog.pauseStart.getTime()) / 60000 + ); + workLog.totalPauseMinutes = (workLog.totalPauseMinutes || 0) + pauseMinutes; + } + + workLog.status = WorkLogStatus.ACTIVE; + workLog.pauseStart = undefined; + + return this.workLogRepo.save(workLog); + } + + async endWork(tenantId: string, workLogId: string, notes?: string): Promise { + const workLog = await this.workLogRepo.findOne({ + where: { id: workLogId, tenantId }, + }); + + if (!workLog) { + throw new Error('Work log not found'); + } + + if (workLog.status === WorkLogStatus.COMPLETED) { + throw new Error('Work log is already completed'); + } + + // Handle if was paused + if (workLog.status === WorkLogStatus.PAUSED && workLog.pauseStart) { + const pauseMinutes = Math.round( + (Date.now() - workLog.pauseStart.getTime()) / 60000 + ); + workLog.totalPauseMinutes = (workLog.totalPauseMinutes || 0) + pauseMinutes; + } + + workLog.endTime = new Date(); + workLog.status = WorkLogStatus.COMPLETED; + if (notes) { + workLog.notes = notes; + } + + // Duration is calculated by trigger but we can do it here too + const totalMinutes = Math.round( + (workLog.endTime.getTime() - workLog.startTime.getTime()) / 60000 + ); + workLog.durationMinutes = totalMinutes - (workLog.totalPauseMinutes || 0); + + if (workLog.laborRate) { + workLog.laborTotal = (workLog.durationMinutes / 60) * Number(workLog.laborRate); + if (workLog.isOvertime) { + workLog.laborTotal *= workLog.overtimeMultiplier; + } + } + + return this.workLogRepo.save(workLog); + } + + async cancelWork(tenantId: string, workLogId: string, reason?: string): Promise { + await this.workLogRepo.update( + { id: workLogId, tenantId }, + { + status: WorkLogStatus.CANCELLED, + notes: reason, + } + ); + } + + async getById(tenantId: string, workLogId: string): Promise { + return this.workLogRepo.findOne({ + where: { id: workLogId, tenantId }, + }); + } + + async getActiveWorkForTechnician(tenantId: string, technicianId: string): Promise { + return this.workLogRepo.findOne({ + where: { + tenantId, + technicianId, + status: WorkLogStatus.ACTIVE, + }, + }); + } + + async getWorkLogsForIncident(tenantId: string, incidentId: string): Promise { + return this.workLogRepo.find({ + where: { tenantId, incidentId }, + order: { startTime: 'ASC' }, + }); + } + + async calculateLabor(tenantId: string, incidentId: string): Promise { + const workLogs = await this.workLogRepo.find({ + where: { + tenantId, + incidentId, + status: WorkLogStatus.COMPLETED, + }, + }); + + const summary: LaborSummary = { + totalMinutes: 0, + totalLabor: 0, + regularMinutes: 0, + regularLabor: 0, + overtimeMinutes: 0, + overtimeLabor: 0, + activities: [], + }; + + const activityMap = new Map(); + + for (const log of workLogs) { + const minutes = log.durationMinutes || 0; + const labor = log.laborTotal ? Number(log.laborTotal) : 0; + + summary.totalMinutes += minutes; + summary.totalLabor += labor; + + if (log.isOvertime) { + summary.overtimeMinutes += minutes; + summary.overtimeLabor += labor; + } else { + summary.regularMinutes += minutes; + summary.regularLabor += labor; + } + + const existing = activityMap.get(log.activityCode); + if (existing) { + existing.minutes += minutes; + existing.labor += labor; + } else { + activityMap.set(log.activityCode, { + name: log.activityName || log.activityCode, + minutes, + labor, + }); + } + } + + summary.activities = Array.from(activityMap.entries()).map(([code, data]) => ({ + activityCode: code, + activityName: data.name, + minutes: data.minutes, + labor: data.labor, + })); + + return summary; + } + + async getTechnicianWorkHistory( + tenantId: string, + technicianId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.workLogRepo + .createQueryBuilder('wl') + .where('wl.tenant_id = :tenantId', { tenantId }) + .andWhere('wl.technician_id = :technicianId', { technicianId }) + .andWhere('wl.start_time BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('wl.start_time', 'DESC') + .getMany(); + } + + // ======================== + // ACTIVITY CATALOG + // ======================== + + async createActivity(tenantId: string, dto: CreateActivityDto): Promise { + const activity = this.activityRepo.create({ + tenantId, + ...dto, + }); + return this.activityRepo.save(activity); + } + + async getActivities( + tenantId: string, + options?: { category?: string; serviceTypeCode?: string; isActive?: boolean } + ): Promise { + const qb = this.activityRepo.createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId }) + .orderBy('a.category', 'ASC') + .addOrderBy('a.name', 'ASC'); + + if (options?.category) { + qb.andWhere('a.category = :category', { category: options.category }); + } + if (options?.serviceTypeCode) { + qb.andWhere('a.service_type_code = :serviceTypeCode', { serviceTypeCode: options.serviceTypeCode }); + } + if (options?.isActive !== undefined) { + qb.andWhere('a.is_active = :isActive', { isActive: options.isActive }); + } + + return qb.getMany(); + } + + async getActivityByCode(tenantId: string, code: string): Promise { + return this.activityRepo.findOne({ + where: { tenantId, code }, + }); + } + + async updateActivity( + tenantId: string, + activityId: string, + dto: Partial + ): Promise { + await this.activityRepo.update({ id: activityId, tenantId }, dto); + } + + async deactivateActivity(tenantId: string, activityId: string): Promise { + await this.activityRepo.update({ id: activityId, tenantId }, { isActive: false }); + } +}