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:
parent
7e0d4ee841
commit
dad6575a3c
47
src/main.ts
47
src/main.ts
@ -42,6 +42,13 @@ import { createSkillController } from './modules/dispatch/controllers/skill.cont
|
||||
import { createShiftController } from './modules/dispatch/controllers/shift.controller';
|
||||
import { createRuleController } from './modules/dispatch/controllers/rule.controller';
|
||||
|
||||
// Field Service Module Controllers
|
||||
import { createChecklistController } from './modules/field-service/controllers/checklist.controller';
|
||||
import { createWorkLogController } from './modules/field-service/controllers/worklog.controller';
|
||||
import { createDiagnosisController as createFieldDiagnosisController } from './modules/field-service/controllers/diagnosis.controller';
|
||||
import { createSyncController } from './modules/field-service/controllers/sync.controller';
|
||||
import { createCheckinController } from './modules/field-service/controllers/checkin.controller';
|
||||
|
||||
// Payment Terminals Module
|
||||
import { PaymentTerminalsModule } from './modules/payment-terminals';
|
||||
|
||||
@ -99,6 +106,19 @@ import { DispatchRule } from './modules/dispatch/entities/dispatch-rule.entity';
|
||||
import { EscalationRule } from './modules/dispatch/entities/escalation-rule.entity';
|
||||
import { DispatchLog } from './modules/dispatch/entities/dispatch-log.entity';
|
||||
|
||||
// Entities - Field Service
|
||||
import { ServiceChecklist } from './modules/field-service/entities/service-checklist.entity';
|
||||
import { ChecklistItemTemplate } from './modules/field-service/entities/checklist-item-template.entity';
|
||||
import { ChecklistResponse } from './modules/field-service/entities/checklist-response.entity';
|
||||
import { ChecklistItemResponse } from './modules/field-service/entities/checklist-item-response.entity';
|
||||
import { WorkLog } from './modules/field-service/entities/work-log.entity';
|
||||
import { DiagnosisRecord } from './modules/field-service/entities/diagnosis-record.entity';
|
||||
import { ActivityCatalog } from './modules/field-service/entities/activity-catalog.entity';
|
||||
import { RootCauseCatalog } from './modules/field-service/entities/root-cause-catalog.entity';
|
||||
import { FieldEvidence } from './modules/field-service/entities/field-evidence.entity';
|
||||
import { FieldCheckin } from './modules/field-service/entities/field-checkin.entity';
|
||||
import { OfflineQueueItem } from './modules/field-service/entities/offline-queue-item.entity';
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
@ -164,6 +184,18 @@ const AppDataSource = new DataSource({
|
||||
DispatchRule,
|
||||
EscalationRule,
|
||||
DispatchLog,
|
||||
// Field Service
|
||||
ServiceChecklist,
|
||||
ChecklistItemTemplate,
|
||||
ChecklistResponse,
|
||||
ChecklistItemResponse,
|
||||
WorkLog,
|
||||
DiagnosisRecord,
|
||||
ActivityCatalog,
|
||||
RootCauseCatalog,
|
||||
FieldEvidence,
|
||||
FieldCheckin,
|
||||
OfflineQueueItem,
|
||||
],
|
||||
synchronize: process.env.NODE_ENV === 'development',
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
@ -231,6 +263,14 @@ async function bootstrap() {
|
||||
app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource));
|
||||
console.log('📋 Dispatch module initialized');
|
||||
|
||||
// Field Service Module Routes
|
||||
app.use('/api/v1/field/checklists', createChecklistController(AppDataSource));
|
||||
app.use('/api/v1/field/worklog', createWorkLogController(AppDataSource));
|
||||
app.use('/api/v1/field/diagnosis', createFieldDiagnosisController(AppDataSource));
|
||||
app.use('/api/v1/field/sync', createSyncController(AppDataSource));
|
||||
app.use('/api/v1/field/checkins', createCheckinController(AppDataSource));
|
||||
console.log('📱 Field Service module initialized');
|
||||
|
||||
// Payment Terminals Module
|
||||
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
|
||||
app.use('/api/v1', paymentTerminals.router);
|
||||
@ -274,6 +314,13 @@ async function bootstrap() {
|
||||
shifts: '/api/v1/dispatch/shifts',
|
||||
rules: '/api/v1/dispatch/rules',
|
||||
},
|
||||
field: {
|
||||
checklists: '/api/v1/field/checklists',
|
||||
worklog: '/api/v1/field/worklog',
|
||||
diagnosis: '/api/v1/field/diagnosis',
|
||||
sync: '/api/v1/field/sync',
|
||||
checkins: '/api/v1/field/checkins',
|
||||
},
|
||||
},
|
||||
documentation: '/api/v1/docs',
|
||||
});
|
||||
|
||||
175
src/modules/field-service/controllers/checkin.controller.ts
Normal file
175
src/modules/field-service/controllers/checkin.controller.ts
Normal 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;
|
||||
}
|
||||
293
src/modules/field-service/controllers/checklist.controller.ts
Normal file
293
src/modules/field-service/controllers/checklist.controller.ts
Normal 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;
|
||||
}
|
||||
259
src/modules/field-service/controllers/diagnosis.controller.ts
Normal file
259
src/modules/field-service/controllers/diagnosis.controller.ts
Normal 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;
|
||||
}
|
||||
10
src/modules/field-service/controllers/index.ts
Normal file
10
src/modules/field-service/controllers/index.ts
Normal 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';
|
||||
169
src/modules/field-service/controllers/sync.controller.ts
Normal file
169
src/modules/field-service/controllers/sync.controller.ts
Normal 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;
|
||||
}
|
||||
231
src/modules/field-service/controllers/worklog.controller.ts
Normal file
231
src/modules/field-service/controllers/worklog.controller.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
113
src/modules/field-service/entities/checklist-response.entity.ts
Normal file
113
src/modules/field-service/entities/checklist-response.entity.ts
Normal 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;
|
||||
}
|
||||
141
src/modules/field-service/entities/diagnosis-record.entity.ts
Normal file
141
src/modules/field-service/entities/diagnosis-record.entity.ts
Normal 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;
|
||||
}
|
||||
93
src/modules/field-service/entities/field-checkin.entity.ts
Normal file
93
src/modules/field-service/entities/field-checkin.entity.ts
Normal 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;
|
||||
}
|
||||
126
src/modules/field-service/entities/field-evidence.entity.ts
Normal file
126
src/modules/field-service/entities/field-evidence.entity.ts
Normal 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;
|
||||
}
|
||||
16
src/modules/field-service/entities/index.ts
Normal file
16
src/modules/field-service/entities/index.ts
Normal 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';
|
||||
115
src/modules/field-service/entities/offline-queue-item.entity.ts
Normal file
115
src/modules/field-service/entities/offline-queue-item.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
125
src/modules/field-service/entities/work-log.entity.ts
Normal file
125
src/modules/field-service/entities/work-log.entity.ts
Normal 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;
|
||||
}
|
||||
10
src/modules/field-service/index.ts
Normal file
10
src/modules/field-service/index.ts
Normal 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';
|
||||
296
src/modules/field-service/services/checkin.service.ts
Normal file
296
src/modules/field-service/services/checkin.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
360
src/modules/field-service/services/checklist.service.ts
Normal file
360
src/modules/field-service/services/checklist.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
351
src/modules/field-service/services/diagnosis.service.ts
Normal file
351
src/modules/field-service/services/diagnosis.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/modules/field-service/services/index.ts
Normal file
10
src/modules/field-service/services/index.ts
Normal 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';
|
||||
388
src/modules/field-service/services/offline-sync.service.ts
Normal file
388
src/modules/field-service/services/offline-sync.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
359
src/modules/field-service/services/worklog.service.ts
Normal file
359
src/modules/field-service/services/worklog.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user