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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 02:43:03 -06:00
parent 7e0d4ee841
commit dad6575a3c
26 changed files with 4044 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
// 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;
}

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
// 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<string, any>;
@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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<FieldCheckin>;
constructor(private dataSource: DataSource) {
this.checkinRepo = dataSource.getRepository(FieldCheckin);
}
// ========================
// CHECK-IN/OUT OPERATIONS
// ========================
async checkin(tenantId: string, dto: CheckinDto): Promise<FieldCheckin> {
// 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<FieldCheckin> {
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<FieldCheckin | null> {
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<FieldCheckin | null> {
return this.checkinRepo.findOne({
where: { id, tenantId },
});
}
async getActiveCheckin(tenantId: string, technicianId: string): Promise<FieldCheckin | null> {
return this.checkinRepo.findOne({
where: {
tenantId,
technicianId,
checkoutTime: IsNull(),
},
order: { checkinTime: 'DESC' },
});
}
async getCheckinsForIncident(tenantId: string, incidentId: string): Promise<FieldCheckin[]> {
return this.checkinRepo.find({
where: { tenantId, incidentId },
order: { checkinTime: 'DESC' },
});
}
async getTechnicianCheckins(
tenantId: string,
technicianId: string,
startDate: Date,
endDate: Date
): Promise<FieldCheckin[]> {
return this.checkinRepo.find({
where: {
tenantId,
technicianId,
checkinTime: Between(startDate, endDate),
},
order: { checkinTime: 'DESC' },
});
}
async getTechnicianLocationInfo(
tenantId: string,
technicianId: string
): Promise<TechnicianLocationInfo> {
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<FieldCheckin[]> {
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<string, { checkins: number; totalMinutes: number }>();
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,
};
}
}

View File

@ -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<ServiceChecklist>;
private itemTemplateRepo: Repository<ChecklistItemTemplate>;
private responseRepo: Repository<ChecklistResponse>;
private itemResponseRepo: Repository<ChecklistItemResponse>;
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<ServiceChecklist> {
const checklist = this.checklistRepo.create({
tenantId,
...dto,
});
return this.checklistRepo.save(checklist);
}
async getChecklistById(tenantId: string, id: string): Promise<ServiceChecklist | null> {
return this.checklistRepo.findOne({
where: { id, tenantId },
relations: ['items'],
});
}
async getChecklistForServiceType(
tenantId: string,
serviceTypeCode: string
): Promise<ServiceChecklist | null> {
return this.checklistRepo.findOne({
where: { tenantId, serviceTypeCode, isActive: true },
relations: ['items'],
order: { version: 'DESC' },
});
}
async getDefaultChecklist(tenantId: string): Promise<ServiceChecklist | null> {
return this.checklistRepo.findOne({
where: { tenantId, isDefault: true, isActive: true },
relations: ['items'],
});
}
async findAllChecklists(
tenantId: string,
options?: { isActive?: boolean; serviceTypeCode?: string }
): Promise<ServiceChecklist[]> {
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<CreateChecklistDto>
): Promise<ServiceChecklist | null> {
await this.checklistRepo.update({ id, tenantId }, dto);
return this.getChecklistById(tenantId, id);
}
async deactivateChecklist(tenantId: string, id: string): Promise<void> {
await this.checklistRepo.update({ id, tenantId }, { isActive: false });
}
// ========================
// CHECKLIST ITEMS
// ========================
async addChecklistItem(
tenantId: string,
checklistId: string,
dto: CreateChecklistItemDto
): Promise<ChecklistItemTemplate> {
const item = this.itemTemplateRepo.create({
tenantId,
checklistId,
...dto,
});
return this.itemTemplateRepo.save(item);
}
async addChecklistItems(
tenantId: string,
checklistId: string,
items: CreateChecklistItemDto[]
): Promise<ChecklistItemTemplate[]> {
const entities = items.map((dto) =>
this.itemTemplateRepo.create({
tenantId,
checklistId,
...dto,
})
);
return this.itemTemplateRepo.save(entities);
}
async updateChecklistItem(
tenantId: string,
itemId: string,
dto: Partial<CreateChecklistItemDto>
): Promise<void> {
await this.itemTemplateRepo.update({ id: itemId, tenantId }, dto);
}
async deleteChecklistItem(tenantId: string, itemId: string): Promise<void> {
await this.itemTemplateRepo.delete({ id: itemId, tenantId });
}
async reorderChecklistItems(tenantId: string, checklistId: string, itemOrder: { id: string; order: number }[]): Promise<void> {
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<ChecklistResponse> {
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<ChecklistResponse | null> {
return this.responseRepo.findOne({
where: { id: responseId, tenantId },
relations: ['itemResponses'],
});
}
async getChecklistResponsesForIncident(tenantId: string, incidentId: string): Promise<ChecklistResponse[]> {
return this.responseRepo.find({
where: { tenantId, incidentId },
relations: ['itemResponses'],
order: { startedAt: 'DESC' },
});
}
async saveItemResponse(
tenantId: string,
responseId: string,
dto: SaveItemResponseDto
): Promise<ChecklistItemResponse> {
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<ChecklistResponse> {
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<void> {
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<ChecklistResponse[]> {
return this.responseRepo.find({
where: { tenantId, technicianId },
order: { startedAt: 'DESC' },
take: limit,
});
}
}

View File

@ -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<string, any>;
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<string, Obd2CodeInfo> = {
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<DiagnosisRecord>;
private rootCauseRepo: Repository<RootCauseCatalog>;
constructor(private dataSource: DataSource) {
this.diagnosisRepo = dataSource.getRepository(DiagnosisRecord);
this.rootCauseRepo = dataSource.getRepository(RootCauseCatalog);
}
// ========================
// DIAGNOSIS RECORDS
// ========================
async createDiagnosis(tenantId: string, dto: CreateDiagnosisDto): Promise<DiagnosisRecord> {
const diagnosis = this.diagnosisRepo.create({
tenantId,
...dto,
});
return this.diagnosisRepo.save(diagnosis);
}
async getById(tenantId: string, id: string): Promise<DiagnosisRecord | null> {
return this.diagnosisRepo.findOne({
where: { id, tenantId },
});
}
async getDiagnosesForIncident(tenantId: string, incidentId: string): Promise<DiagnosisRecord[]> {
return this.diagnosisRepo.find({
where: { tenantId, incidentId },
order: { createdAt: 'DESC' },
});
}
async getDiagnosesForVehicle(tenantId: string, vehicleId: string): Promise<DiagnosisRecord[]> {
return this.diagnosisRepo.find({
where: { tenantId, vehicleId },
order: { createdAt: 'DESC' },
});
}
async updateDiagnosis(
tenantId: string,
id: string,
dto: Partial<CreateDiagnosisDto>
): Promise<DiagnosisRecord | null> {
await this.diagnosisRepo.update({ id, tenantId }, dto);
return this.getById(tenantId, id);
}
async deleteDiagnosis(tenantId: string, id: string): Promise<void> {
await this.diagnosisRepo.delete({ id, tenantId });
}
async findDiagnoses(
tenantId: string,
options?: {
technicianId?: string;
diagnosisType?: DiagnosisType;
severity?: DiagnosisSeverity;
startDate?: Date;
endDate?: Date;
limit?: number;
}
): Promise<DiagnosisRecord[]> {
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<Obd2CodeInfo[]> {
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<DiagnosisRecord[]> {
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<RootCauseCatalog> {
const rootCause = this.rootCauseRepo.create({
tenantId,
...dto,
});
return this.rootCauseRepo.save(rootCause);
}
async getRootCauseCatalog(
tenantId: string,
options?: { category?: string; isActive?: boolean }
): Promise<RootCauseCatalog[]> {
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<RootCauseCatalog | null> {
return this.rootCauseRepo.findOne({
where: { tenantId, code },
});
}
async getRootCauseCategories(tenantId: string): Promise<string[]> {
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<CreateRootCauseDto>
): Promise<void> {
await this.rootCauseRepo.update({ id, tenantId }, dto);
}
async deactivateRootCause(tenantId: string, id: string): Promise<void> {
await this.rootCauseRepo.update({ id, tenantId }, { isActive: false });
}
// ========================
// STATISTICS
// ========================
async getDiagnosisStats(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{
total: number;
byType: Record<string, number>;
bySeverity: Record<string, number>;
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<string, number> = {};
const bySeverity: Record<string, number> = {};
const rootCauseCount: Record<string, number> = {};
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,
};
}
}

View File

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

View File

@ -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<string, any>;
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<string, any>;
serverData: Record<string, any>;
conflictFields: string[];
}
export class OfflineSyncService {
private queueRepo: Repository<OfflineQueueItem>;
private checklistResponseRepo: Repository<ChecklistResponse>;
private workLogRepo: Repository<WorkLog>;
private diagnosisRepo: Repository<DiagnosisRecord>;
private evidenceRepo: Repository<FieldEvidence>;
private checkinRepo: Repository<FieldCheckin>;
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<OfflineQueueItem> {
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<OfflineQueueItem[]> {
return this.queueRepo.find({
where: {
deviceId,
status: In([SyncStatus.PENDING, SyncStatus.FAILED]),
},
order: { priority: 'DESC', createdAt: 'ASC' },
});
}
async getPendingCount(deviceId: string): Promise<number> {
return this.queueRepo.count({
where: {
deviceId,
status: In([SyncStatus.PENDING, SyncStatus.FAILED]),
},
});
}
// ========================
// SYNC PROCESSING
// ========================
async processQueue(deviceId: string, tenantId: string): Promise<SyncResult> {
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<string> {
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<string, any>): Promise<string> {
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<string, any>): Promise<string> {
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<string, any>): Promise<string> {
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<string, any>): Promise<string> {
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<string, any>): Promise<string> {
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<ConflictInfo[]> {
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<string, any>
): Promise<void> {
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<number> {
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<number> {
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<string, number> = {};
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,
};
}
}

View File

@ -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<WorkLog>;
private activityRepo: Repository<ActivityCatalog>;
constructor(private dataSource: DataSource) {
this.workLogRepo = dataSource.getRepository(WorkLog);
this.activityRepo = dataSource.getRepository(ActivityCatalog);
}
// ========================
// WORK LOG OPERATIONS
// ========================
async startWork(tenantId: string, dto: StartWorkDto): Promise<WorkLog> {
// 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<WorkLog> {
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<WorkLog> {
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<WorkLog> {
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<void> {
await this.workLogRepo.update(
{ id: workLogId, tenantId },
{
status: WorkLogStatus.CANCELLED,
notes: reason,
}
);
}
async getById(tenantId: string, workLogId: string): Promise<WorkLog | null> {
return this.workLogRepo.findOne({
where: { id: workLogId, tenantId },
});
}
async getActiveWorkForTechnician(tenantId: string, technicianId: string): Promise<WorkLog | null> {
return this.workLogRepo.findOne({
where: {
tenantId,
technicianId,
status: WorkLogStatus.ACTIVE,
},
});
}
async getWorkLogsForIncident(tenantId: string, incidentId: string): Promise<WorkLog[]> {
return this.workLogRepo.find({
where: { tenantId, incidentId },
order: { startTime: 'ASC' },
});
}
async calculateLabor(tenantId: string, incidentId: string): Promise<LaborSummary> {
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<string, { name: string; minutes: number; labor: number }>();
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<WorkLog[]> {
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<ActivityCatalog> {
const activity = this.activityRepo.create({
tenantId,
...dto,
});
return this.activityRepo.save(activity);
}
async getActivities(
tenantId: string,
options?: { category?: string; serviceTypeCode?: string; isActive?: boolean }
): Promise<ActivityCatalog[]> {
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<ActivityCatalog | null> {
return this.activityRepo.findOne({
where: { tenantId, code },
});
}
async updateActivity(
tenantId: string,
activityId: string,
dto: Partial<CreateActivityDto>
): Promise<void> {
await this.activityRepo.update({ id: activityId, tenantId }, dto);
}
async deactivateActivity(tenantId: string, activityId: string): Promise<void> {
await this.activityRepo.update({ id: activityId, tenantId }, { isActive: false });
}
}