From b927bafeb04aaca4627e0d238fc0056a4eed843a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 01:33:59 -0600 Subject: [PATCH] feat(MMD-014): Implement GPS module backend - Add 5 entities: GpsDevice, GpsPosition, Geofence, GeofenceEvent, RouteSegment - Add 4 services with full business logic - Add 4 controllers with REST endpoints - Integrate GPS module into main.ts - Endpoints: /api/v1/gps/devices, positions, geofences, routes - Features: device management, position tracking, geofencing, route calculation Co-Authored-By: Claude Opus 4.5 --- src/main.ts | 32 ++ .../gps/controllers/geofence.controller.ts | 258 ++++++++++ .../gps/controllers/gps-device.controller.ts | 219 +++++++++ .../controllers/gps-position.controller.ts | 236 +++++++++ src/modules/gps/controllers/index.ts | 9 + .../controllers/route-segment.controller.ts | 206 ++++++++ .../gps/entities/geofence-event.entity.ts | 94 ++++ src/modules/gps/entities/geofence.entity.ts | 119 +++++ src/modules/gps/entities/gps-device.entity.ts | 127 +++++ .../gps/entities/gps-position.entity.ts | 88 ++++ src/modules/gps/entities/index.ts | 10 + .../gps/entities/route-segment.entity.ts | 132 +++++ src/modules/gps/index.ts | 42 ++ src/modules/gps/services/geofence.service.ts | 459 ++++++++++++++++++ .../gps/services/gps-device.service.ts | 326 +++++++++++++ .../gps/services/gps-position.service.ts | 390 +++++++++++++++ src/modules/gps/services/index.ts | 9 + .../gps/services/route-segment.service.ts | 428 ++++++++++++++++ 18 files changed, 3184 insertions(+) create mode 100644 src/modules/gps/controllers/geofence.controller.ts create mode 100644 src/modules/gps/controllers/gps-device.controller.ts create mode 100644 src/modules/gps/controllers/gps-position.controller.ts create mode 100644 src/modules/gps/controllers/index.ts create mode 100644 src/modules/gps/controllers/route-segment.controller.ts create mode 100644 src/modules/gps/entities/geofence-event.entity.ts create mode 100644 src/modules/gps/entities/geofence.entity.ts create mode 100644 src/modules/gps/entities/gps-device.entity.ts create mode 100644 src/modules/gps/entities/gps-position.entity.ts create mode 100644 src/modules/gps/entities/index.ts create mode 100644 src/modules/gps/entities/route-segment.entity.ts create mode 100644 src/modules/gps/index.ts create mode 100644 src/modules/gps/services/geofence.service.ts create mode 100644 src/modules/gps/services/gps-device.service.ts create mode 100644 src/modules/gps/services/gps-position.service.ts create mode 100644 src/modules/gps/services/index.ts create mode 100644 src/modules/gps/services/route-segment.service.ts diff --git a/src/main.ts b/src/main.ts index 9ef47ac..089da18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,12 @@ import { createPartController } from './modules/parts-management/controllers/par import { createSupplierController } from './modules/parts-management/controllers/supplier.controller'; import { createCustomersRouter } from './modules/customers/controllers/customers.controller'; +// GPS Module Controllers +import { createGpsDeviceController } from './modules/gps/controllers/gps-device.controller'; +import { createGpsPositionController } from './modules/gps/controllers/gps-position.controller'; +import { createGeofenceController } from './modules/gps/controllers/geofence.controller'; +import { createRouteSegmentController } from './modules/gps/controllers/route-segment.controller'; + // Payment Terminals Module import { PaymentTerminalsModule } from './modules/payment-terminals'; @@ -57,6 +63,13 @@ import { TenantTerminalConfig } from './modules/payment-terminals/entities/tenan import { TerminalPayment } from './modules/payment-terminals/entities/terminal-payment.entity'; import { TerminalWebhookEvent } from './modules/payment-terminals/entities/terminal-webhook-event.entity'; +// Entities - GPS +import { GpsDevice } from './modules/gps/entities/gps-device.entity'; +import { GpsPosition } from './modules/gps/entities/gps-position.entity'; +import { Geofence } from './modules/gps/entities/geofence.entity'; +import { GeofenceEvent } from './modules/gps/entities/geofence-event.entity'; +import { RouteSegment } from './modules/gps/entities/route-segment.entity'; + // Load environment variables config(); @@ -101,6 +114,12 @@ const AppDataSource = new DataSource({ TenantTerminalConfig, TerminalPayment, TerminalWebhookEvent, + // GPS + GpsDevice, + GpsPosition, + Geofence, + GeofenceEvent, + RouteSegment, ], synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', @@ -147,6 +166,13 @@ async function bootstrap() { app.use('/api/v1/suppliers', createSupplierController(AppDataSource)); app.use('/api/v1/customers', createCustomersRouter(AppDataSource)); + // GPS Module Routes + app.use('/api/v1/gps/devices', createGpsDeviceController(AppDataSource)); + app.use('/api/v1/gps/positions', createGpsPositionController(AppDataSource)); + app.use('/api/v1/gps/geofences', createGeofenceController(AppDataSource)); + app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource)); + console.log('📡 GPS module initialized'); + // Payment Terminals Module const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); app.use('/api/v1', paymentTerminals.router); @@ -172,6 +198,12 @@ async function bootstrap() { paymentTerminals: '/api/v1/payment-terminals', mercadopago: '/api/v1/mercadopago', clip: '/api/v1/clip', + gps: { + devices: '/api/v1/gps/devices', + positions: '/api/v1/gps/positions', + geofences: '/api/v1/gps/geofences', + routes: '/api/v1/gps/routes', + }, }, documentation: '/api/v1/docs', }); diff --git a/src/modules/gps/controllers/geofence.controller.ts b/src/modules/gps/controllers/geofence.controller.ts new file mode 100644 index 0000000..67e7053 --- /dev/null +++ b/src/modules/gps/controllers/geofence.controller.ts @@ -0,0 +1,258 @@ +/** + * Geofence Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for geofence management. + * Module: MMD-014 GPS Integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { GeofenceService, GeofenceFilters } from '../services/geofence.service'; +import { GeofenceType, GeofenceCategory } from '../entities/geofence.entity'; +import { GeofenceEventType } from '../entities/geofence-event.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createGeofenceController(dataSource: DataSource): Router { + const router = Router(); + const service = new GeofenceService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Create a new geofence + * POST /api/gps/geofences + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const geofence = await service.create(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(geofence); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List geofences with filters + * GET /api/gps/geofences + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: GeofenceFilters = { + category: req.query.category as GeofenceCategory, + geofenceType: req.query.type as GeofenceType, + isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get geofence statistics + * GET /api/gps/geofences/stats + */ + router.get('/stats', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getStats(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Check point against all geofences + * POST /api/gps/geofences/check + */ + router.post('/check', async (req: TenantRequest, res: Response) => { + try { + const { latitude, longitude } = req.body; + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ error: 'latitude and longitude are required' }); + } + const results = await service.checkPointAgainstGeofences(req.tenantId!, latitude, longitude); + res.json(results); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Find geofences containing a point + * POST /api/gps/geofences/containing + */ + router.post('/containing', async (req: TenantRequest, res: Response) => { + try { + const { latitude, longitude } = req.body; + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ error: 'latitude and longitude are required' }); + } + const geofences = await service.findGeofencesContainingPoint(req.tenantId!, latitude, longitude); + res.json(geofences); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get geofence by ID + * GET /api/gps/geofences/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const geofence = await service.findById(req.tenantId!, req.params.id); + if (!geofence) { + return res.status(404).json({ error: 'Geofence not found' }); + } + res.json(geofence); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get geofence by code + * GET /api/gps/geofences/code/:code + */ + router.get('/code/:code', async (req: TenantRequest, res: Response) => { + try { + const geofence = await service.findByCode(req.tenantId!, req.params.code); + if (!geofence) { + return res.status(404).json({ error: 'Geofence not found' }); + } + res.json(geofence); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update geofence + * PATCH /api/gps/geofences/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const geofence = await service.update(req.tenantId!, req.params.id, req.body); + if (!geofence) { + return res.status(404).json({ error: 'Geofence not found' }); + } + res.json(geofence); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Deactivate geofence + * DELETE /api/gps/geofences/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deactivate(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Geofence not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ============ EVENTS ============ + + /** + * Record a geofence event + * POST /api/gps/geofences/events + */ + router.post('/events', async (req: TenantRequest, res: Response) => { + try { + const event = await service.createEvent(req.tenantId!, { + ...req.body, + eventTime: new Date(req.body.eventTime), + }); + res.status(201).json(event); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get geofence events + * GET /api/gps/geofences/events + */ + router.get('/events', async (req: TenantRequest, res: Response) => { + try { + const filters = { + geofenceId: req.query.geofenceId as string, + deviceId: req.query.deviceId as string, + unitId: req.query.unitId as string, + eventType: req.query.eventType as GeofenceEventType, + startTime: req.query.startTime ? new Date(req.query.startTime as string) : undefined, + endTime: req.query.endTime ? new Date(req.query.endTime as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 50, 200), + }; + + const result = await service.getEvents(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get events for a specific geofence + * GET /api/gps/geofences/:id/events + */ + router.get('/:id/events', async (req: TenantRequest, res: Response) => { + try { + const filters = { + geofenceId: req.params.id, + startTime: req.query.startTime ? new Date(req.query.startTime as string) : undefined, + endTime: req.query.endTime ? new Date(req.query.endTime as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 50, 200), + }; + + const result = await service.getEvents(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/gps-device.controller.ts b/src/modules/gps/controllers/gps-device.controller.ts new file mode 100644 index 0000000..2d4ed29 --- /dev/null +++ b/src/modules/gps/controllers/gps-device.controller.ts @@ -0,0 +1,219 @@ +/** + * GpsDevice Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for GPS device management. + * Module: MMD-014 GPS Integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { GpsDeviceService, GpsDeviceFilters } from '../services/gps-device.service'; +import { GpsPlatform, UnitType } from '../entities/gps-device.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createGpsDeviceController(dataSource: DataSource): Router { + const router = Router(); + const service = new GpsDeviceService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Register a new GPS device + * POST /api/gps/devices + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const device = await service.create(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(device); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List GPS devices with filters + * GET /api/gps/devices + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: GpsDeviceFilters = { + unitId: req.query.unitId as string, + unitType: req.query.unitType as UnitType, + platform: req.query.platform as GpsPlatform, + isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device statistics + * GET /api/gps/devices/stats + */ + router.get('/stats', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getStats(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get all active devices with positions (for map view) + * GET /api/gps/devices/active + */ + router.get('/active', async (req: TenantRequest, res: Response) => { + try { + const devices = await service.findActiveWithPositions(req.tenantId!); + res.json(devices); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get devices with stale positions + * GET /api/gps/devices/stale + */ + router.get('/stale', async (req: TenantRequest, res: Response) => { + try { + const thresholdMinutes = parseInt(req.query.threshold as string, 10) || 10; + const devices = await service.findStaleDevices(req.tenantId!, thresholdMinutes); + res.json(devices); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by ID + * GET /api/gps/devices/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const device = await service.findById(req.tenantId!, req.params.id); + if (!device) { + return res.status(404).json({ error: 'GPS device not found' }); + } + res.json(device); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by unit ID + * GET /api/gps/devices/unit/:unitId + */ + router.get('/unit/:unitId', async (req: TenantRequest, res: Response) => { + try { + const device = await service.findByUnitId(req.tenantId!, req.params.unitId); + if (!device) { + return res.status(404).json({ error: 'GPS device not found for unit' }); + } + res.json(device); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by external ID + * GET /api/gps/devices/external/:externalId + */ + router.get('/external/:externalId', async (req: TenantRequest, res: Response) => { + try { + const device = await service.findByExternalId(req.tenantId!, req.params.externalId); + if (!device) { + return res.status(404).json({ error: 'GPS device not found' }); + } + res.json(device); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update GPS device + * PATCH /api/gps/devices/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const device = await service.update(req.tenantId!, req.params.id, req.body); + if (!device) { + return res.status(404).json({ error: 'GPS device not found' }); + } + res.json(device); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Update last position + * PATCH /api/gps/devices/:id/position + */ + router.patch('/:id/position', async (req: TenantRequest, res: Response) => { + try { + const { latitude, longitude, timestamp } = req.body; + const device = await service.updateLastPosition(req.tenantId!, req.params.id, { + latitude, + longitude, + timestamp: new Date(timestamp), + }); + if (!device) { + return res.status(404).json({ error: 'GPS device not found' }); + } + res.json(device); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Deactivate GPS device + * DELETE /api/gps/devices/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deactivate(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'GPS device not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/gps-position.controller.ts b/src/modules/gps/controllers/gps-position.controller.ts new file mode 100644 index 0000000..ff01b12 --- /dev/null +++ b/src/modules/gps/controllers/gps-position.controller.ts @@ -0,0 +1,236 @@ +/** + * GpsPosition Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for GPS position tracking. + * Module: MMD-014 GPS Integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { GpsPositionService, PositionFilters } from '../services/gps-position.service'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createGpsPositionController(dataSource: DataSource): Router { + const router = Router(); + const service = new GpsPositionService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Record a new GPS position + * POST /api/gps/positions + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const position = await service.create(req.tenantId!, { + ...req.body, + deviceTime: new Date(req.body.deviceTime), + fixTime: req.body.fixTime ? new Date(req.body.fixTime) : undefined, + }); + res.status(201).json(position); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Record multiple positions in batch + * POST /api/gps/positions/batch + */ + router.post('/batch', async (req: TenantRequest, res: Response) => { + try { + const positions = req.body.positions.map((p: any) => ({ + ...p, + deviceTime: new Date(p.deviceTime), + fixTime: p.fixTime ? new Date(p.fixTime) : undefined, + })); + const count = await service.createBatch(req.tenantId!, positions); + res.status(201).json({ inserted: count }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get position history + * GET /api/gps/positions + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: PositionFilters = { + deviceId: req.query.deviceId as string, + unitId: req.query.unitId as string, + startTime: req.query.startTime ? new Date(req.query.startTime as string) : undefined, + endTime: req.query.endTime ? new Date(req.query.endTime as string) : undefined, + minSpeed: req.query.minSpeed ? parseFloat(req.query.minSpeed as string) : undefined, + maxSpeed: req.query.maxSpeed ? parseFloat(req.query.maxSpeed as string) : undefined, + isValid: req.query.isValid === 'true' ? true : req.query.isValid === 'false' ? false : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 100, 1000), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get last position for a device + * GET /api/gps/positions/last/:deviceId + */ + router.get('/last/:deviceId', async (req: TenantRequest, res: Response) => { + try { + const position = await service.getLastPosition(req.tenantId!, req.params.deviceId); + if (!position) { + return res.status(404).json({ error: 'No position found for device' }); + } + res.json(position); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get last positions for multiple devices + * POST /api/gps/positions/last + */ + router.post('/last', async (req: TenantRequest, res: Response) => { + try { + const { deviceIds } = req.body; + if (!Array.isArray(deviceIds)) { + return res.status(400).json({ error: 'deviceIds must be an array' }); + } + const positions = await service.getLastPositions(req.tenantId!, deviceIds); + res.json(positions); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get track for a device (for map polyline) + * GET /api/gps/positions/track/:deviceId + */ + router.get('/track/:deviceId', async (req: TenantRequest, res: Response) => { + try { + const startTime = new Date(req.query.startTime as string); + const endTime = new Date(req.query.endTime as string); + const simplify = req.query.simplify === 'true'; + + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { + return res.status(400).json({ error: 'Valid startTime and endTime are required' }); + } + + const track = await service.getTrack( + req.tenantId!, + req.params.deviceId, + startTime, + endTime, + simplify + ); + res.json(track); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get track summary statistics + * GET /api/gps/positions/track/:deviceId/summary + */ + router.get('/track/:deviceId/summary', async (req: TenantRequest, res: Response) => { + try { + const startTime = new Date(req.query.startTime as string); + const endTime = new Date(req.query.endTime as string); + + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { + return res.status(400).json({ error: 'Valid startTime and endTime are required' }); + } + + const summary = await service.getTrackSummary( + req.tenantId!, + req.params.deviceId, + startTime, + endTime + ); + + if (!summary) { + return res.status(404).json({ error: 'No track data found' }); + } + + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Calculate distance between two points + * POST /api/gps/positions/distance + */ + router.post('/distance', async (req: TenantRequest, res: Response) => { + try { + const { lat1, lng1, lat2, lng2 } = req.body; + const distance = service.calculateDistance(lat1, lng1, lat2, lng2); + res.json({ distanceKm: distance }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get position by ID + * GET /api/gps/positions/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const position = await service.findById(req.tenantId!, req.params.id); + if (!position) { + return res.status(404).json({ error: 'Position not found' }); + } + res.json(position); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Delete old positions (data retention) + * DELETE /api/gps/positions/old + */ + router.delete('/old', async (req: TenantRequest, res: Response) => { + try { + const beforeDate = new Date(req.query.before as string); + if (isNaN(beforeDate.getTime())) { + return res.status(400).json({ error: 'Valid before date is required' }); + } + + const deleted = await service.deleteOldPositions(req.tenantId!, beforeDate); + res.json({ deleted }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/index.ts b/src/modules/gps/controllers/index.ts new file mode 100644 index 0000000..b27f9d5 --- /dev/null +++ b/src/modules/gps/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * GPS Module Controllers + * Module: MMD-014 GPS Integration + */ + +export * from './gps-device.controller'; +export * from './gps-position.controller'; +export * from './geofence.controller'; +export * from './route-segment.controller'; diff --git a/src/modules/gps/controllers/route-segment.controller.ts b/src/modules/gps/controllers/route-segment.controller.ts new file mode 100644 index 0000000..6a1f582 --- /dev/null +++ b/src/modules/gps/controllers/route-segment.controller.ts @@ -0,0 +1,206 @@ +/** + * RouteSegment Controller + * Mecánicas Diesel - ERP Suite + * + * REST API endpoints for route segment management. + * Module: MMD-014 GPS Integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { RouteSegmentService, RouteSegmentFilters } from '../services/route-segment.service'; +import { SegmentType } from '../entities/route-segment.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createRouteSegmentController(dataSource: DataSource): Router { + const router = Router(); + const service = new RouteSegmentService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Create a route segment + * POST /api/gps/routes + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const segment = await service.create(req.tenantId!, { + ...req.body, + startTime: new Date(req.body.startTime), + endTime: new Date(req.body.endTime), + }); + res.status(201).json(segment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Calculate route from positions + * POST /api/gps/routes/calculate + */ + router.post('/calculate', async (req: TenantRequest, res: Response) => { + try { + const { deviceId, startTime, endTime, incidentId, segmentType } = req.body; + + if (!deviceId || !startTime || !endTime) { + return res.status(400).json({ error: 'deviceId, startTime, and endTime are required' }); + } + + const segment = await service.calculateRouteFromPositions( + req.tenantId!, + deviceId, + new Date(startTime), + new Date(endTime), + incidentId, + segmentType + ); + + if (!segment) { + return res.status(404).json({ error: 'Not enough positions to calculate route' }); + } + + res.status(201).json(segment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List route segments with filters + * GET /api/gps/routes + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: RouteSegmentFilters = { + incidentId: req.query.incidentId as string, + unitId: req.query.unitId as string, + deviceId: req.query.deviceId as string, + segmentType: req.query.segmentType as SegmentType, + isValid: req.query.isValid === 'true' ? true : req.query.isValid === 'false' ? false : undefined, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get route summary for an incident + * GET /api/gps/routes/incident/:incidentId/summary + */ + router.get('/incident/:incidentId/summary', async (req: TenantRequest, res: Response) => { + try { + const summary = await service.getIncidentRouteSummary(req.tenantId!, req.params.incidentId); + if (!summary) { + return res.status(404).json({ error: 'No route segments found for incident' }); + } + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get segments for an incident + * GET /api/gps/routes/incident/:incidentId + */ + router.get('/incident/:incidentId', async (req: TenantRequest, res: Response) => { + try { + const segments = await service.findByIncident(req.tenantId!, req.params.incidentId); + res.json(segments); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get unit statistics + * GET /api/gps/routes/unit/:unitId/stats + */ + router.get('/unit/:unitId/stats', async (req: TenantRequest, res: Response) => { + try { + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + const stats = await service.getUnitStats(req.tenantId!, req.params.unitId, startDate, endDate); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get route segment by ID + * GET /api/gps/routes/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const segment = await service.findById(req.tenantId!, req.params.id); + if (!segment) { + return res.status(404).json({ error: 'Route segment not found' }); + } + res.json(segment); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update segment validity + * PATCH /api/gps/routes/:id/validity + */ + router.patch('/:id/validity', async (req: TenantRequest, res: Response) => { + try { + const { isValid, notes } = req.body; + const segment = await service.updateValidity(req.tenantId!, req.params.id, isValid, notes); + if (!segment) { + return res.status(404).json({ error: 'Route segment not found' }); + } + res.json(segment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete route segment + * DELETE /api/gps/routes/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.delete(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Route segment not found' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/entities/geofence-event.entity.ts b/src/modules/gps/entities/geofence-event.entity.ts new file mode 100644 index 0000000..7264b10 --- /dev/null +++ b/src/modules/gps/entities/geofence-event.entity.ts @@ -0,0 +1,94 @@ +/** + * GeofenceEvent Entity + * Mecánicas Diesel - ERP Suite + * + * Represents geofence entry/exit events. + * Module: MMD-014 GPS Integration + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Geofence } from './geofence.entity'; +import { GpsDevice } from './gps-device.entity'; +import { GpsPosition } from './gps-position.entity'; + +export enum GeofenceEventType { + ENTER = 'enter', + EXIT = 'exit', + DWELL = 'dwell', +} + +@Entity({ name: 'geofence_events', schema: 'gps_tracking' }) +@Index('idx_geofence_events_tenant', ['tenantId']) +@Index('idx_geofence_events_geofence', ['geofenceId']) +@Index('idx_geofence_events_device', ['deviceId']) +@Index('idx_geofence_events_unit', ['unitId']) +@Index('idx_geofence_events_time', ['eventTime']) +export class GeofenceEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'geofence_id', type: 'uuid' }) + geofenceId: string; + + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Column({ name: 'unit_id', type: 'uuid' }) + unitId: string; + + // Event type + @Column({ + name: 'event_type', + type: 'varchar', + length: 10, + }) + eventType: GeofenceEventType; + + // Position that triggered the event + @Column({ name: 'position_id', type: 'uuid', nullable: true }) + positionId?: string; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + latitude: number; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + longitude: number; + + // Timestamps + @Column({ name: 'event_time', type: 'timestamptz' }) + eventTime: Date; + + @Column({ name: 'processed_at', type: 'timestamptz', default: () => 'NOW()' }) + processedAt: Date; + + // Link to operations + @Column({ name: 'related_incident_id', type: 'uuid', nullable: true }) + relatedIncidentId?: string; // FK to rescue_order if applicable + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Relations + @ManyToOne(() => Geofence, geofence => geofence.events) + @JoinColumn({ name: 'geofence_id' }) + geofence: Geofence; + + @ManyToOne(() => GpsDevice, device => device.geofenceEvents) + @JoinColumn({ name: 'device_id' }) + device: GpsDevice; + + @ManyToOne(() => GpsPosition) + @JoinColumn({ name: 'position_id' }) + position?: GpsPosition; +} diff --git a/src/modules/gps/entities/geofence.entity.ts b/src/modules/gps/entities/geofence.entity.ts new file mode 100644 index 0000000..7e2c112 --- /dev/null +++ b/src/modules/gps/entities/geofence.entity.ts @@ -0,0 +1,119 @@ +/** + * Geofence Entity + * Mecánicas Diesel - ERP Suite + * + * Represents geofences (areas of interest) for event detection. + * Module: MMD-014 GPS Integration + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { GeofenceEvent } from './geofence-event.entity'; + +export enum GeofenceType { + CIRCLE = 'circle', + POLYGON = 'polygon', +} + +export enum GeofenceCategory { + BASE = 'base', // Workshop/base location + COVERAGE = 'coverage', // Service coverage area + RESTRICTED = 'restricted', // Restricted zones + HIGH_RISK = 'high_risk', // High risk areas + CLIENT = 'client', // Client locations + CUSTOM = 'custom', // Custom geofences +} + +@Entity({ name: 'geofences', schema: 'gps_tracking' }) +@Index('idx_geofences_tenant', ['tenantId']) +@Index('idx_geofences_category', ['category']) +export class Geofence { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Identification + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + code?: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Geofence type + @Column({ + name: 'geofence_type', + type: 'varchar', + length: 20, + default: GeofenceType.CIRCLE, + }) + geofenceType: GeofenceType; + + // Circle type properties + @Column({ name: 'center_lat', type: 'decimal', precision: 10, scale: 7, nullable: true }) + centerLat?: number; + + @Column({ name: 'center_lng', type: 'decimal', precision: 10, scale: 7, nullable: true }) + centerLng?: number; + + @Column({ name: 'radius_meters', type: 'decimal', precision: 10, scale: 2, nullable: true }) + radiusMeters?: number; + + // Polygon type properties (GeoJSON format) + @Column({ name: 'polygon_geojson', type: 'jsonb', nullable: true }) + polygonGeojson?: Record; + // Example: {"type": "Polygon", "coordinates": [[[lng1,lat1],[lng2,lat2],...]]} + + // Category + @Column({ + type: 'varchar', + length: 30, + default: GeofenceCategory.CUSTOM, + }) + category: GeofenceCategory; + + // Event configuration + @Column({ name: 'trigger_on_enter', type: 'boolean', default: true }) + triggerOnEnter: boolean; + + @Column({ name: 'trigger_on_exit', type: 'boolean', default: true }) + triggerOnExit: boolean; + + @Column({ name: 'dwell_time_seconds', type: 'integer', default: 0 }) + dwellTimeSeconds: number; // Minimum time to trigger event + + // Status + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'varchar', length: 7, default: '#3388ff' }) + color: string; // Hex color for map display + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + // Relations + @OneToMany(() => GeofenceEvent, event => event.geofence) + events: GeofenceEvent[]; +} diff --git a/src/modules/gps/entities/gps-device.entity.ts b/src/modules/gps/entities/gps-device.entity.ts new file mode 100644 index 0000000..b4f0f93 --- /dev/null +++ b/src/modules/gps/entities/gps-device.entity.ts @@ -0,0 +1,127 @@ +/** + * GpsDevice Entity + * Mecánicas Diesel - ERP Suite + * + * Represents GPS tracking devices linked to fleet units. + * Module: MMD-014 GPS Integration + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { GpsPosition } from './gps-position.entity'; +import { GeofenceEvent } from './geofence-event.entity'; +import { RouteSegment } from './route-segment.entity'; + +export enum GpsPlatform { + TRACCAR = 'traccar', + WIALON = 'wialon', + SAMSARA = 'samsara', + GEOTAB = 'geotab', + MANUAL = 'manual', +} + +export enum UnitType { + VEHICLE = 'vehicle', + TRAILER = 'trailer', + EQUIPMENT = 'equipment', + TECHNICIAN = 'technician', +} + +@Entity({ name: 'gps_devices', schema: 'gps_tracking' }) +@Index('idx_gps_devices_tenant', ['tenantId']) +@Index('idx_gps_devices_unit', ['unitId']) +@Index('idx_gps_devices_external', ['externalDeviceId']) +@Index('idx_gps_devices_platform', ['platform']) +export class GpsDevice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Link to fleet unit + @Column({ name: 'unit_id', type: 'uuid' }) + unitId: string; + + @Column({ + name: 'unit_type', + type: 'varchar', + length: 20, + default: UnitType.VEHICLE, + }) + unitType: UnitType; + + // External platform identification + @Column({ name: 'external_device_id', type: 'varchar', length: 100 }) + externalDeviceId: string; + + @Column({ + type: 'varchar', + length: 30, + default: GpsPlatform.TRACCAR, + }) + platform: GpsPlatform; + + // Device identifiers + @Column({ type: 'varchar', length: 20, nullable: true }) + imei?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ name: 'phone_number', type: 'varchar', length: 20, nullable: true }) + phoneNumber?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + model?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + manufacturer?: string; + + // Status + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_position_at', type: 'timestamptz', nullable: true }) + lastPositionAt?: Date; + + @Column({ name: 'last_position_lat', type: 'decimal', precision: 10, scale: 7, nullable: true }) + lastPositionLat?: number; + + @Column({ name: 'last_position_lng', type: 'decimal', precision: 10, scale: 7, nullable: true }) + lastPositionLng?: number; + + // Configuration + @Column({ name: 'position_interval_seconds', type: 'integer', default: 30 }) + positionIntervalSeconds: number; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + // Relations + @OneToMany(() => GpsPosition, position => position.device) + positions: GpsPosition[]; + + @OneToMany(() => GeofenceEvent, event => event.device) + geofenceEvents: GeofenceEvent[]; + + @OneToMany(() => RouteSegment, segment => segment.device) + routeSegments: RouteSegment[]; +} diff --git a/src/modules/gps/entities/gps-position.entity.ts b/src/modules/gps/entities/gps-position.entity.ts new file mode 100644 index 0000000..a6090a1 --- /dev/null +++ b/src/modules/gps/entities/gps-position.entity.ts @@ -0,0 +1,88 @@ +/** + * GpsPosition Entity + * Mecánicas Diesel - ERP Suite + * + * Represents GPS positions (time-series data). + * Module: MMD-014 GPS Integration + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { GpsDevice } from './gps-device.entity'; + +@Entity({ name: 'gps_positions', schema: 'gps_tracking' }) +@Index('idx_gps_positions_tenant', ['tenantId']) +@Index('idx_gps_positions_device', ['deviceId']) +@Index('idx_gps_positions_unit', ['unitId']) +@Index('idx_gps_positions_time', ['deviceTime']) +export class GpsPosition { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Column({ name: 'unit_id', type: 'uuid' }) + unitId: string; + + // Position + @Column({ type: 'decimal', precision: 10, scale: 7 }) + latitude: number; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + longitude: number; + + @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true }) + altitude?: number; + + // Movement + @Column({ type: 'decimal', precision: 6, scale: 2, nullable: true }) + speed?: number; // km/h + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + course?: number; // degrees (0 = north) + + // Precision + @Column({ type: 'decimal', precision: 6, scale: 2, nullable: true }) + accuracy?: number; // meters + + @Column({ type: 'decimal', precision: 4, scale: 2, nullable: true }) + hdop?: number; // Horizontal Dilution of Precision + + // Additional attributes from device + @Column({ type: 'jsonb', default: {} }) + attributes: Record; + // Can contain: ignition, fuel, odometer, engineHours, batteryLevel, etc. + + // Timestamps + @Column({ name: 'device_time', type: 'timestamptz' }) + deviceTime: Date; + + @Column({ name: 'server_time', type: 'timestamptz', default: () => 'NOW()' }) + serverTime: Date; + + @Column({ name: 'fix_time', type: 'timestamptz', nullable: true }) + fixTime?: Date; + + // Validation + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => GpsDevice, device => device.positions) + @JoinColumn({ name: 'device_id' }) + device: GpsDevice; +} diff --git a/src/modules/gps/entities/index.ts b/src/modules/gps/entities/index.ts new file mode 100644 index 0000000..367e06c --- /dev/null +++ b/src/modules/gps/entities/index.ts @@ -0,0 +1,10 @@ +/** + * GPS Module Entities + * Module: MMD-014 GPS Integration + */ + +export * from './gps-device.entity'; +export * from './gps-position.entity'; +export * from './geofence.entity'; +export * from './geofence-event.entity'; +export * from './route-segment.entity'; diff --git a/src/modules/gps/entities/route-segment.entity.ts b/src/modules/gps/entities/route-segment.entity.ts new file mode 100644 index 0000000..175f375 --- /dev/null +++ b/src/modules/gps/entities/route-segment.entity.ts @@ -0,0 +1,132 @@ +/** + * RouteSegment Entity + * Mecánicas Diesel - ERP Suite + * + * Represents route segments for distance calculation and billing. + * Module: MMD-014 GPS Integration + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { GpsDevice } from './gps-device.entity'; +import { GpsPosition } from './gps-position.entity'; + +export enum SegmentType { + TO_INCIDENT = 'to_incident', // Trip to incident + AT_INCIDENT = 'at_incident', // At incident location + RETURN = 'return', // Return trip + BETWEEN_INCIDENTS = 'between_incidents', // Between multiple incidents + OTHER = 'other', +} + +@Entity({ name: 'route_segments', schema: 'gps_tracking' }) +@Index('idx_route_segments_tenant', ['tenantId']) +@Index('idx_route_segments_incident', ['incidentId']) +@Index('idx_route_segments_unit', ['unitId']) +@Index('idx_route_segments_type', ['segmentType']) +@Index('idx_route_segments_time', ['startTime']) +export class RouteSegment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Link to incident/service + @Column({ name: 'incident_id', type: 'uuid', nullable: true }) + incidentId?: string; // FK to rescue_order + + @Column({ name: 'unit_id', type: 'uuid' }) + unitId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId?: string; + + // Start/end positions + @Column({ name: 'start_position_id', type: 'uuid', nullable: true }) + startPositionId?: string; + + @Column({ name: 'end_position_id', type: 'uuid', nullable: true }) + endPositionId?: string; + + // Coordinates (denormalized for quick access) + @Column({ name: 'start_lat', type: 'decimal', precision: 10, scale: 7 }) + startLat: number; + + @Column({ name: 'start_lng', type: 'decimal', precision: 10, scale: 7 }) + startLng: number; + + @Column({ name: 'end_lat', type: 'decimal', precision: 10, scale: 7 }) + endLat: number; + + @Column({ name: 'end_lng', type: 'decimal', precision: 10, scale: 7 }) + endLng: number; + + // Distances + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 3 }) + distanceKm: number; + + @Column({ name: 'raw_distance_km', type: 'decimal', precision: 10, scale: 3, nullable: true }) + rawDistanceKm?: number; // Before filters + + // Times + @Column({ name: 'start_time', type: 'timestamptz' }) + startTime: Date; + + @Column({ name: 'end_time', type: 'timestamptz' }) + endTime: Date; + + @Column({ name: 'duration_minutes', type: 'decimal', precision: 8, scale: 2, nullable: true }) + durationMinutes?: number; + + // Segment type + @Column({ + name: 'segment_type', + type: 'varchar', + length: 30, + default: SegmentType.OTHER, + }) + segmentType: SegmentType; + + // Validation + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid: boolean; + + @Column({ name: 'validation_notes', type: 'text', nullable: true }) + validationNotes?: string; + + // Encoded polyline for visualization + @Column({ name: 'encoded_polyline', type: 'text', nullable: true }) + encodedPolyline?: string; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'calculated_at', type: 'timestamptz', default: () => 'NOW()' }) + calculatedAt: Date; + + // Relations + @ManyToOne(() => GpsDevice, device => device.routeSegments, { nullable: true }) + @JoinColumn({ name: 'device_id' }) + device?: GpsDevice; + + @ManyToOne(() => GpsPosition, { nullable: true }) + @JoinColumn({ name: 'start_position_id' }) + startPosition?: GpsPosition; + + @ManyToOne(() => GpsPosition, { nullable: true }) + @JoinColumn({ name: 'end_position_id' }) + endPosition?: GpsPosition; +} diff --git a/src/modules/gps/index.ts b/src/modules/gps/index.ts new file mode 100644 index 0000000..58a90d6 --- /dev/null +++ b/src/modules/gps/index.ts @@ -0,0 +1,42 @@ +/** + * GPS Module + * Mecánicas Diesel - ERP Suite + * Module: MMD-014 GPS Integration + */ + +// Entities +export { GpsDevice, GpsPlatform, UnitType } from './entities/gps-device.entity'; +export { GpsPosition } from './entities/gps-position.entity'; +export { Geofence, GeofenceType, GeofenceCategory } from './entities/geofence.entity'; +export { GeofenceEvent, GeofenceEventType } from './entities/geofence-event.entity'; +export { RouteSegment, SegmentType } from './entities/route-segment.entity'; + +// Services +export { + GpsDeviceService, + CreateGpsDeviceDto, + UpdateGpsDeviceDto, + GpsDeviceFilters, +} from './services/gps-device.service'; +export { + GpsPositionService, + CreatePositionDto, + PositionFilters, +} from './services/gps-position.service'; +export { + GeofenceService, + CreateGeofenceDto, + UpdateGeofenceDto, + GeofenceFilters, +} from './services/geofence.service'; +export { + RouteSegmentService, + CreateRouteSegmentDto, + RouteSegmentFilters, +} from './services/route-segment.service'; + +// Controllers +export { createGpsDeviceController } from './controllers/gps-device.controller'; +export { createGpsPositionController } from './controllers/gps-position.controller'; +export { createGeofenceController } from './controllers/geofence.controller'; +export { createRouteSegmentController } from './controllers/route-segment.controller'; diff --git a/src/modules/gps/services/geofence.service.ts b/src/modules/gps/services/geofence.service.ts new file mode 100644 index 0000000..41d2180 --- /dev/null +++ b/src/modules/gps/services/geofence.service.ts @@ -0,0 +1,459 @@ +/** + * Geofence Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for geofence management and point-in-polygon detection. + * Module: MMD-014 GPS Integration + */ + +import { Repository, DataSource } from 'typeorm'; +import { Geofence, GeofenceType, GeofenceCategory } from '../entities/geofence.entity'; +import { GeofenceEvent, GeofenceEventType } from '../entities/geofence-event.entity'; + +// DTOs +export interface CreateGeofenceDto { + name: string; + code?: string; + description?: string; + geofenceType: GeofenceType; + // For circle type + centerLat?: number; + centerLng?: number; + radiusMeters?: number; + // For polygon type + polygonGeojson?: Record; + category?: GeofenceCategory; + triggerOnEnter?: boolean; + triggerOnExit?: boolean; + dwellTimeSeconds?: number; + color?: string; + metadata?: Record; + createdBy?: string; +} + +export interface UpdateGeofenceDto { + name?: string; + code?: string; + description?: string; + centerLat?: number; + centerLng?: number; + radiusMeters?: number; + polygonGeojson?: Record; + category?: GeofenceCategory; + triggerOnEnter?: boolean; + triggerOnExit?: boolean; + dwellTimeSeconds?: number; + isActive?: boolean; + color?: string; + metadata?: Record; +} + +export interface GeofenceFilters { + category?: GeofenceCategory; + geofenceType?: GeofenceType; + isActive?: boolean; + search?: string; +} + +export interface CreateGeofenceEventDto { + geofenceId: string; + deviceId: string; + unitId: string; + eventType: GeofenceEventType; + positionId?: string; + latitude: number; + longitude: number; + eventTime: Date; + relatedIncidentId?: string; + metadata?: Record; +} + +export interface GeofenceCheckResult { + geofenceId: string; + geofenceName: string; + isInside: boolean; + distanceToCenter?: number; // For circle geofences +} + +export class GeofenceService { + private geofenceRepository: Repository; + private eventRepository: Repository; + + constructor(dataSource: DataSource) { + this.geofenceRepository = dataSource.getRepository(Geofence); + this.eventRepository = dataSource.getRepository(GeofenceEvent); + } + + /** + * Create a new geofence + */ + async create(tenantId: string, dto: CreateGeofenceDto): Promise { + // Validate based on type + if (dto.geofenceType === GeofenceType.CIRCLE) { + if (dto.centerLat === undefined || dto.centerLng === undefined || dto.radiusMeters === undefined) { + throw new Error('Circle geofence requires centerLat, centerLng, and radiusMeters'); + } + } else if (dto.geofenceType === GeofenceType.POLYGON) { + if (!dto.polygonGeojson || !dto.polygonGeojson.coordinates) { + throw new Error('Polygon geofence requires valid GeoJSON coordinates'); + } + } + + const geofence = this.geofenceRepository.create({ + tenantId, + name: dto.name, + code: dto.code, + description: dto.description, + geofenceType: dto.geofenceType, + centerLat: dto.centerLat, + centerLng: dto.centerLng, + radiusMeters: dto.radiusMeters, + polygonGeojson: dto.polygonGeojson, + category: dto.category || GeofenceCategory.CUSTOM, + triggerOnEnter: dto.triggerOnEnter !== false, + triggerOnExit: dto.triggerOnExit !== false, + dwellTimeSeconds: dto.dwellTimeSeconds || 0, + color: dto.color || '#3388ff', + metadata: dto.metadata || {}, + createdBy: dto.createdBy, + isActive: true, + }); + + return this.geofenceRepository.save(geofence); + } + + /** + * Find geofence by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.geofenceRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Find geofence by code + */ + async findByCode(tenantId: string, code: string): Promise { + return this.geofenceRepository.findOne({ + where: { tenantId, code }, + }); + } + + /** + * List geofences with filters + */ + async findAll( + tenantId: string, + filters: GeofenceFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.geofenceRepository.createQueryBuilder('geofence') + .where('geofence.tenant_id = :tenantId', { tenantId }); + + if (filters.category) { + queryBuilder.andWhere('geofence.category = :category', { category: filters.category }); + } + if (filters.geofenceType) { + queryBuilder.andWhere('geofence.geofence_type = :geofenceType', { geofenceType: filters.geofenceType }); + } + if (filters.isActive !== undefined) { + queryBuilder.andWhere('geofence.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.search) { + queryBuilder.andWhere( + '(geofence.name ILIKE :search OR geofence.code ILIKE :search OR geofence.description ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('geofence.created_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Update geofence + */ + async update(tenantId: string, id: string, dto: UpdateGeofenceDto): Promise { + const geofence = await this.findById(tenantId, id); + if (!geofence) return null; + + Object.assign(geofence, dto); + return this.geofenceRepository.save(geofence); + } + + /** + * Deactivate geofence + */ + async deactivate(tenantId: string, id: string): Promise { + const geofence = await this.findById(tenantId, id); + if (!geofence) return false; + + geofence.isActive = false; + await this.geofenceRepository.save(geofence); + return true; + } + + /** + * Check if a point is inside a geofence + */ + isPointInGeofence(lat: number, lng: number, geofence: Geofence): boolean { + if (geofence.geofenceType === GeofenceType.CIRCLE) { + return this.isPointInCircle(lat, lng, geofence.centerLat!, geofence.centerLng!, geofence.radiusMeters!); + } else { + return this.isPointInPolygon(lat, lng, geofence.polygonGeojson!); + } + } + + /** + * Check if a point is inside a circle + */ + private isPointInCircle(lat: number, lng: number, centerLat: number, centerLng: number, radiusMeters: number): boolean { + const distance = this.calculateDistance(lat, lng, centerLat, centerLng); + return distance <= radiusMeters; + } + + /** + * Check if a point is inside a polygon using ray casting algorithm + */ + private isPointInPolygon(lat: number, lng: number, geojson: Record): boolean { + if (!geojson.coordinates || !Array.isArray(geojson.coordinates[0])) { + return false; + } + + const polygon = geojson.coordinates[0]; // First ring is exterior + let inside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i][0]; // longitude + const yi = polygon[i][1]; // latitude + const xj = polygon[j][0]; + const yj = polygon[j][1]; + + if ( + yi > lat !== yj > lat && + lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi + ) { + inside = !inside; + } + } + + return inside; + } + + /** + * Calculate distance between two points in meters (Haversine) + */ + private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371000; // Earth radius in meters + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRad(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Check point against all active geofences + */ + async checkPointAgainstGeofences( + tenantId: string, + lat: number, + lng: number + ): Promise { + const geofences = await this.geofenceRepository.find({ + where: { tenantId, isActive: true }, + }); + + return geofences.map(geofence => { + const isInside = this.isPointInGeofence(lat, lng, geofence); + let distanceToCenter: number | undefined; + + if (geofence.geofenceType === GeofenceType.CIRCLE) { + distanceToCenter = this.calculateDistance(lat, lng, geofence.centerLat!, geofence.centerLng!); + } + + return { + geofenceId: geofence.id, + geofenceName: geofence.name, + isInside, + distanceToCenter, + }; + }); + } + + /** + * Find geofences containing a point + */ + async findGeofencesContainingPoint( + tenantId: string, + lat: number, + lng: number + ): Promise { + const geofences = await this.geofenceRepository.find({ + where: { tenantId, isActive: true }, + }); + + return geofences.filter(geofence => this.isPointInGeofence(lat, lng, geofence)); + } + + /** + * Record a geofence event + */ + async createEvent(tenantId: string, dto: CreateGeofenceEventDto): Promise { + const event = this.eventRepository.create({ + tenantId, + geofenceId: dto.geofenceId, + deviceId: dto.deviceId, + unitId: dto.unitId, + eventType: dto.eventType, + positionId: dto.positionId, + latitude: dto.latitude, + longitude: dto.longitude, + eventTime: dto.eventTime, + relatedIncidentId: dto.relatedIncidentId, + metadata: dto.metadata || {}, + }); + + return this.eventRepository.save(event); + } + + /** + * Get geofence events + */ + async getEvents( + tenantId: string, + filters: { + geofenceId?: string; + deviceId?: string; + unitId?: string; + eventType?: GeofenceEventType; + startTime?: Date; + endTime?: Date; + } = {}, + pagination = { page: 1, limit: 50 } + ) { + const queryBuilder = this.eventRepository.createQueryBuilder('event') + .where('event.tenant_id = :tenantId', { tenantId }); + + if (filters.geofenceId) { + queryBuilder.andWhere('event.geofence_id = :geofenceId', { geofenceId: filters.geofenceId }); + } + if (filters.deviceId) { + queryBuilder.andWhere('event.device_id = :deviceId', { deviceId: filters.deviceId }); + } + if (filters.unitId) { + queryBuilder.andWhere('event.unit_id = :unitId', { unitId: filters.unitId }); + } + if (filters.eventType) { + queryBuilder.andWhere('event.event_type = :eventType', { eventType: filters.eventType }); + } + if (filters.startTime) { + queryBuilder.andWhere('event.event_time >= :startTime', { startTime: filters.startTime }); + } + if (filters.endTime) { + queryBuilder.andWhere('event.event_time <= :endTime', { endTime: filters.endTime }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('event.event_time', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get geofence statistics + */ + async getStats(tenantId: string): Promise<{ + total: number; + active: number; + byCategory: Record; + byType: Record; + totalEvents: number; + }> { + const [total, active, categoryCounts, typeCounts, totalEvents] = await Promise.all([ + this.geofenceRepository.count({ where: { tenantId } }), + this.geofenceRepository.count({ where: { tenantId, isActive: true } }), + this.geofenceRepository + .createQueryBuilder('geofence') + .select('geofence.category', 'category') + .addSelect('COUNT(*)', 'count') + .where('geofence.tenant_id = :tenantId', { tenantId }) + .groupBy('geofence.category') + .getRawMany(), + this.geofenceRepository + .createQueryBuilder('geofence') + .select('geofence.geofence_type', 'geofenceType') + .addSelect('COUNT(*)', 'count') + .where('geofence.tenant_id = :tenantId', { tenantId }) + .groupBy('geofence.geofence_type') + .getRawMany(), + this.eventRepository.count({ where: { tenantId } }), + ]); + + const byCategory: Record = { + [GeofenceCategory.BASE]: 0, + [GeofenceCategory.COVERAGE]: 0, + [GeofenceCategory.RESTRICTED]: 0, + [GeofenceCategory.HIGH_RISK]: 0, + [GeofenceCategory.CLIENT]: 0, + [GeofenceCategory.CUSTOM]: 0, + }; + + const byType: Record = { + [GeofenceType.CIRCLE]: 0, + [GeofenceType.POLYGON]: 0, + }; + + for (const row of categoryCounts) { + if (row.category) { + byCategory[row.category as GeofenceCategory] = parseInt(row.count, 10); + } + } + + for (const row of typeCounts) { + if (row.geofenceType) { + byType[row.geofenceType as GeofenceType] = parseInt(row.count, 10); + } + } + + return { + total, + active, + byCategory, + byType, + totalEvents, + }; + } +} diff --git a/src/modules/gps/services/gps-device.service.ts b/src/modules/gps/services/gps-device.service.ts new file mode 100644 index 0000000..0a47598 --- /dev/null +++ b/src/modules/gps/services/gps-device.service.ts @@ -0,0 +1,326 @@ +/** + * GpsDevice Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for GPS device management. + * Module: MMD-014 GPS Integration + */ + +import { Repository, DataSource } from 'typeorm'; +import { GpsDevice, GpsPlatform, UnitType } from '../entities/gps-device.entity'; + +// DTOs +export interface CreateGpsDeviceDto { + unitId: string; + unitType?: UnitType; + externalDeviceId: string; + platform?: GpsPlatform; + imei?: string; + serialNumber?: string; + phoneNumber?: string; + model?: string; + manufacturer?: string; + positionIntervalSeconds?: number; + metadata?: Record; + createdBy?: string; +} + +export interface UpdateGpsDeviceDto { + externalDeviceId?: string; + platform?: GpsPlatform; + imei?: string; + serialNumber?: string; + phoneNumber?: string; + model?: string; + manufacturer?: string; + positionIntervalSeconds?: number; + isActive?: boolean; + metadata?: Record; +} + +export interface GpsDeviceFilters { + unitId?: string; + unitType?: UnitType; + platform?: GpsPlatform; + isActive?: boolean; + search?: string; +} + +export interface LastPosition { + latitude: number; + longitude: number; + timestamp: Date; +} + +export class GpsDeviceService { + private deviceRepository: Repository; + + constructor(dataSource: DataSource) { + this.deviceRepository = dataSource.getRepository(GpsDevice); + } + + /** + * Register a new GPS device + */ + async create(tenantId: string, dto: CreateGpsDeviceDto): Promise { + // Check for duplicate external device ID + const existing = await this.deviceRepository.findOne({ + where: { tenantId, externalDeviceId: dto.externalDeviceId }, + }); + + if (existing) { + throw new Error(`Device with external ID ${dto.externalDeviceId} already exists`); + } + + // Check if unit already has a device + const unitDevice = await this.deviceRepository.findOne({ + where: { tenantId, unitId: dto.unitId, isActive: true }, + }); + + if (unitDevice) { + throw new Error(`Unit ${dto.unitId} already has an active GPS device`); + } + + const device = this.deviceRepository.create({ + tenantId, + unitId: dto.unitId, + unitType: dto.unitType || UnitType.VEHICLE, + externalDeviceId: dto.externalDeviceId, + platform: dto.platform || GpsPlatform.TRACCAR, + imei: dto.imei, + serialNumber: dto.serialNumber, + phoneNumber: dto.phoneNumber, + model: dto.model, + manufacturer: dto.manufacturer, + positionIntervalSeconds: dto.positionIntervalSeconds || 30, + metadata: dto.metadata || {}, + createdBy: dto.createdBy, + isActive: true, + }); + + return this.deviceRepository.save(device); + } + + /** + * Find device by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.deviceRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Find device by external ID + */ + async findByExternalId(tenantId: string, externalDeviceId: string): Promise { + return this.deviceRepository.findOne({ + where: { tenantId, externalDeviceId }, + }); + } + + /** + * Find device by unit ID + */ + async findByUnitId(tenantId: string, unitId: string): Promise { + return this.deviceRepository.findOne({ + where: { tenantId, unitId, isActive: true }, + }); + } + + /** + * List devices with filters + */ + async findAll( + tenantId: string, + filters: GpsDeviceFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.deviceRepository.createQueryBuilder('device') + .where('device.tenant_id = :tenantId', { tenantId }); + + if (filters.unitId) { + queryBuilder.andWhere('device.unit_id = :unitId', { unitId: filters.unitId }); + } + if (filters.unitType) { + queryBuilder.andWhere('device.unit_type = :unitType', { unitType: filters.unitType }); + } + if (filters.platform) { + queryBuilder.andWhere('device.platform = :platform', { platform: filters.platform }); + } + if (filters.isActive !== undefined) { + queryBuilder.andWhere('device.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.search) { + queryBuilder.andWhere( + '(device.external_device_id ILIKE :search OR device.imei ILIKE :search OR device.serial_number ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('device.created_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Update device + */ + async update(tenantId: string, id: string, dto: UpdateGpsDeviceDto): Promise { + const device = await this.findById(tenantId, id); + if (!device) return null; + + // Check external ID uniqueness if changing + if (dto.externalDeviceId && dto.externalDeviceId !== device.externalDeviceId) { + const existing = await this.findByExternalId(tenantId, dto.externalDeviceId); + if (existing) { + throw new Error(`Device with external ID ${dto.externalDeviceId} already exists`); + } + } + + Object.assign(device, dto); + return this.deviceRepository.save(device); + } + + /** + * Update last position (called when new position is received) + */ + async updateLastPosition( + tenantId: string, + id: string, + position: LastPosition + ): Promise { + const device = await this.findById(tenantId, id); + if (!device) return null; + + device.lastPositionLat = position.latitude; + device.lastPositionLng = position.longitude; + device.lastPositionAt = position.timestamp; + + return this.deviceRepository.save(device); + } + + /** + * Deactivate device + */ + async deactivate(tenantId: string, id: string): Promise { + const device = await this.findById(tenantId, id); + if (!device) return false; + + device.isActive = false; + await this.deviceRepository.save(device); + return true; + } + + /** + * Get devices with stale positions (no update in X minutes) + */ + async findStaleDevices(tenantId: string, thresholdMinutes: number = 10): Promise { + const threshold = new Date(Date.now() - thresholdMinutes * 60 * 1000); + + return this.deviceRepository + .createQueryBuilder('device') + .where('device.tenant_id = :tenantId', { tenantId }) + .andWhere('device.is_active = :isActive', { isActive: true }) + .andWhere('(device.last_position_at IS NULL OR device.last_position_at < :threshold)', { threshold }) + .getMany(); + } + + /** + * Get all active devices with last position + */ + async findActiveWithPositions(tenantId: string): Promise { + return this.deviceRepository.find({ + where: { tenantId, isActive: true }, + order: { lastPositionAt: 'DESC' }, + }); + } + + /** + * Get device statistics + */ + async getStats(tenantId: string): Promise<{ + total: number; + active: number; + byPlatform: Record; + byUnitType: Record; + online: number; + offline: number; + }> { + const thresholdMinutes = 10; + const threshold = new Date(Date.now() - thresholdMinutes * 60 * 1000); + + const [total, active, platformCounts, unitTypeCounts, online] = await Promise.all([ + this.deviceRepository.count({ where: { tenantId } }), + this.deviceRepository.count({ where: { tenantId, isActive: true } }), + this.deviceRepository + .createQueryBuilder('device') + .select('device.platform', 'platform') + .addSelect('COUNT(*)', 'count') + .where('device.tenant_id = :tenantId', { tenantId }) + .groupBy('device.platform') + .getRawMany(), + this.deviceRepository + .createQueryBuilder('device') + .select('device.unit_type', 'unitType') + .addSelect('COUNT(*)', 'count') + .where('device.tenant_id = :tenantId', { tenantId }) + .groupBy('device.unit_type') + .getRawMany(), + this.deviceRepository + .createQueryBuilder('device') + .where('device.tenant_id = :tenantId', { tenantId }) + .andWhere('device.is_active = :isActive', { isActive: true }) + .andWhere('device.last_position_at >= :threshold', { threshold }) + .getCount(), + ]); + + const byPlatform: Record = { + [GpsPlatform.TRACCAR]: 0, + [GpsPlatform.WIALON]: 0, + [GpsPlatform.SAMSARA]: 0, + [GpsPlatform.GEOTAB]: 0, + [GpsPlatform.MANUAL]: 0, + }; + + const byUnitType: Record = { + [UnitType.VEHICLE]: 0, + [UnitType.TRAILER]: 0, + [UnitType.EQUIPMENT]: 0, + [UnitType.TECHNICIAN]: 0, + }; + + for (const row of platformCounts) { + if (row.platform) { + byPlatform[row.platform as GpsPlatform] = parseInt(row.count, 10); + } + } + + for (const row of unitTypeCounts) { + if (row.unitType) { + byUnitType[row.unitType as UnitType] = parseInt(row.count, 10); + } + } + + return { + total, + active, + byPlatform, + byUnitType, + online, + offline: active - online, + }; + } +} diff --git a/src/modules/gps/services/gps-position.service.ts b/src/modules/gps/services/gps-position.service.ts new file mode 100644 index 0000000..c455978 --- /dev/null +++ b/src/modules/gps/services/gps-position.service.ts @@ -0,0 +1,390 @@ +/** + * GpsPosition Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for GPS position tracking and history. + * Module: MMD-014 GPS Integration + */ + +import { Repository, DataSource, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { GpsPosition } from '../entities/gps-position.entity'; +import { GpsDevice } from '../entities/gps-device.entity'; + +// DTOs +export interface CreatePositionDto { + deviceId: string; + unitId: string; + latitude: number; + longitude: number; + altitude?: number; + speed?: number; + course?: number; + accuracy?: number; + hdop?: number; + attributes?: Record; + deviceTime: Date; + fixTime?: Date; + isValid?: boolean; +} + +export interface PositionFilters { + deviceId?: string; + unitId?: string; + startTime?: Date; + endTime?: Date; + minSpeed?: number; + maxSpeed?: number; + isValid?: boolean; +} + +export interface PositionPoint { + latitude: number; + longitude: number; + timestamp: Date; + speed?: number; +} + +export interface TrackSummary { + totalPoints: number; + totalDistanceKm: number; + avgSpeed: number; + maxSpeed: number; + duration: number; // minutes + startTime: Date; + endTime: Date; +} + +export class GpsPositionService { + private positionRepository: Repository; + private deviceRepository: Repository; + + constructor(dataSource: DataSource) { + this.positionRepository = dataSource.getRepository(GpsPosition); + this.deviceRepository = dataSource.getRepository(GpsDevice); + } + + /** + * Record a new GPS position + */ + async create(tenantId: string, dto: CreatePositionDto): Promise { + // Validate device exists and belongs to tenant + const device = await this.deviceRepository.findOne({ + where: { id: dto.deviceId, tenantId }, + }); + + if (!device) { + throw new Error(`Device ${dto.deviceId} not found`); + } + + const position = this.positionRepository.create({ + tenantId, + deviceId: dto.deviceId, + unitId: dto.unitId, + latitude: dto.latitude, + longitude: dto.longitude, + altitude: dto.altitude, + speed: dto.speed, + course: dto.course, + accuracy: dto.accuracy, + hdop: dto.hdop, + attributes: dto.attributes || {}, + deviceTime: dto.deviceTime, + fixTime: dto.fixTime, + isValid: dto.isValid !== false, + }); + + const savedPosition = await this.positionRepository.save(position); + + // Update device last position (fire and forget) + this.deviceRepository.update( + { id: dto.deviceId }, + { + lastPositionLat: dto.latitude, + lastPositionLng: dto.longitude, + lastPositionAt: dto.deviceTime, + } + ); + + return savedPosition; + } + + /** + * Record multiple positions in batch + */ + async createBatch(tenantId: string, positions: CreatePositionDto[]): Promise { + if (positions.length === 0) return 0; + + const entities = positions.map(dto => this.positionRepository.create({ + tenantId, + deviceId: dto.deviceId, + unitId: dto.unitId, + latitude: dto.latitude, + longitude: dto.longitude, + altitude: dto.altitude, + speed: dto.speed, + course: dto.course, + accuracy: dto.accuracy, + hdop: dto.hdop, + attributes: dto.attributes || {}, + deviceTime: dto.deviceTime, + fixTime: dto.fixTime, + isValid: dto.isValid !== false, + })); + + const result = await this.positionRepository.insert(entities); + return result.identifiers.length; + } + + /** + * Get position by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.positionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Get position history with filters + */ + async findAll( + tenantId: string, + filters: PositionFilters = {}, + pagination = { page: 1, limit: 100 } + ) { + const queryBuilder = this.positionRepository.createQueryBuilder('pos') + .where('pos.tenant_id = :tenantId', { tenantId }); + + if (filters.deviceId) { + queryBuilder.andWhere('pos.device_id = :deviceId', { deviceId: filters.deviceId }); + } + if (filters.unitId) { + queryBuilder.andWhere('pos.unit_id = :unitId', { unitId: filters.unitId }); + } + if (filters.startTime) { + queryBuilder.andWhere('pos.device_time >= :startTime', { startTime: filters.startTime }); + } + if (filters.endTime) { + queryBuilder.andWhere('pos.device_time <= :endTime', { endTime: filters.endTime }); + } + if (filters.minSpeed !== undefined) { + queryBuilder.andWhere('pos.speed >= :minSpeed', { minSpeed: filters.minSpeed }); + } + if (filters.maxSpeed !== undefined) { + queryBuilder.andWhere('pos.speed <= :maxSpeed', { maxSpeed: filters.maxSpeed }); + } + if (filters.isValid !== undefined) { + queryBuilder.andWhere('pos.is_valid = :isValid', { isValid: filters.isValid }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('pos.device_time', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get last position for a device + */ + async getLastPosition(tenantId: string, deviceId: string): Promise { + return this.positionRepository.findOne({ + where: { tenantId, deviceId }, + order: { deviceTime: 'DESC' }, + }); + } + + /** + * Get last positions for multiple devices + */ + async getLastPositions(tenantId: string, deviceIds: string[]): Promise { + if (deviceIds.length === 0) return []; + + // Using a subquery to get max deviceTime per device + const subQuery = this.positionRepository + .createQueryBuilder('sub') + .select('sub.device_id', 'device_id') + .addSelect('MAX(sub.device_time)', 'max_time') + .where('sub.tenant_id = :tenantId', { tenantId }) + .andWhere('sub.device_id IN (:...deviceIds)', { deviceIds }) + .groupBy('sub.device_id'); + + return this.positionRepository + .createQueryBuilder('pos') + .innerJoin( + `(${subQuery.getQuery()})`, + 'latest', + 'pos.device_id = latest.device_id AND pos.device_time = latest.max_time' + ) + .setParameters(subQuery.getParameters()) + .where('pos.tenant_id = :tenantId', { tenantId }) + .getMany(); + } + + /** + * Get track for a device in a time range + */ + async getTrack( + tenantId: string, + deviceId: string, + startTime: Date, + endTime: Date, + simplify: boolean = false + ): Promise { + const queryBuilder = this.positionRepository + .createQueryBuilder('pos') + .select(['pos.latitude', 'pos.longitude', 'pos.deviceTime', 'pos.speed']) + .where('pos.tenant_id = :tenantId', { tenantId }) + .andWhere('pos.device_id = :deviceId', { deviceId }) + .andWhere('pos.device_time BETWEEN :startTime AND :endTime', { startTime, endTime }) + .andWhere('pos.is_valid = :isValid', { isValid: true }) + .orderBy('pos.device_time', 'ASC'); + + const positions = await queryBuilder.getMany(); + + const points: PositionPoint[] = positions.map(p => ({ + latitude: Number(p.latitude), + longitude: Number(p.longitude), + timestamp: p.deviceTime, + speed: p.speed ? Number(p.speed) : undefined, + })); + + if (simplify && points.length > 500) { + return this.simplifyTrack(points, 500); + } + + return points; + } + + /** + * Get track summary statistics + */ + async getTrackSummary( + tenantId: string, + deviceId: string, + startTime: Date, + endTime: Date + ): Promise { + const result = await this.positionRepository + .createQueryBuilder('pos') + .select('COUNT(*)', 'totalPoints') + .addSelect('AVG(pos.speed)', 'avgSpeed') + .addSelect('MAX(pos.speed)', 'maxSpeed') + .addSelect('MIN(pos.device_time)', 'startTime') + .addSelect('MAX(pos.device_time)', 'endTime') + .where('pos.tenant_id = :tenantId', { tenantId }) + .andWhere('pos.device_id = :deviceId', { deviceId }) + .andWhere('pos.device_time BETWEEN :startTime AND :endTime', { startTime, endTime }) + .andWhere('pos.is_valid = :isValid', { isValid: true }) + .getRawOne(); + + if (!result || result.totalPoints === '0') return null; + + // Calculate distance using positions + const track = await this.getTrack(tenantId, deviceId, startTime, endTime, false); + const totalDistanceKm = this.calculateTrackDistance(track); + + const actualStartTime = new Date(result.startTime); + const actualEndTime = new Date(result.endTime); + const duration = (actualEndTime.getTime() - actualStartTime.getTime()) / (1000 * 60); + + return { + totalPoints: parseInt(result.totalPoints, 10), + totalDistanceKm, + avgSpeed: parseFloat(result.avgSpeed) || 0, + maxSpeed: parseFloat(result.maxSpeed) || 0, + duration, + startTime: actualStartTime, + endTime: actualEndTime, + }; + } + + /** + * Calculate distance between two coordinates using Haversine formula + */ + calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + /** + * Calculate total distance for a track + */ + calculateTrackDistance(points: PositionPoint[]): number { + if (points.length < 2) return 0; + + let totalDistance = 0; + for (let i = 1; i < points.length; i++) { + totalDistance += this.calculateDistance( + points[i - 1].latitude, + points[i - 1].longitude, + points[i].latitude, + points[i].longitude + ); + } + return Math.round(totalDistance * 1000) / 1000; // 3 decimal places + } + + /** + * Simplify track using Douglas-Peucker algorithm (basic implementation) + */ + private simplifyTrack(points: PositionPoint[], maxPoints: number): PositionPoint[] { + if (points.length <= maxPoints) return points; + + // Simple nth-point sampling for now + const step = Math.ceil(points.length / maxPoints); + const simplified: PositionPoint[] = [points[0]]; + + for (let i = step; i < points.length - 1; i += step) { + simplified.push(points[i]); + } + + // Always include last point + simplified.push(points[points.length - 1]); + return simplified; + } + + private toRad(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Delete old positions (for data retention) + */ + async deleteOldPositions(tenantId: string, beforeDate: Date): Promise { + const result = await this.positionRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('device_time < :beforeDate', { beforeDate }) + .execute(); + + return result.affected || 0; + } + + /** + * Get position count for a device + */ + async getPositionCount(tenantId: string, deviceId: string): Promise { + return this.positionRepository.count({ + where: { tenantId, deviceId }, + }); + } +} diff --git a/src/modules/gps/services/index.ts b/src/modules/gps/services/index.ts new file mode 100644 index 0000000..c533e2a --- /dev/null +++ b/src/modules/gps/services/index.ts @@ -0,0 +1,9 @@ +/** + * GPS Module Services + * Module: MMD-014 GPS Integration + */ + +export * from './gps-device.service'; +export * from './gps-position.service'; +export * from './geofence.service'; +export * from './route-segment.service'; diff --git a/src/modules/gps/services/route-segment.service.ts b/src/modules/gps/services/route-segment.service.ts new file mode 100644 index 0000000..f5d8bb0 --- /dev/null +++ b/src/modules/gps/services/route-segment.service.ts @@ -0,0 +1,428 @@ +/** + * RouteSegment Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for route segment calculation and billing. + * Module: MMD-014 GPS Integration + */ + +import { Repository, DataSource } from 'typeorm'; +import { RouteSegment, SegmentType } from '../entities/route-segment.entity'; +import { GpsPosition } from '../entities/gps-position.entity'; + +// DTOs +export interface CreateRouteSegmentDto { + incidentId?: string; + unitId: string; + deviceId?: string; + startPositionId?: string; + endPositionId?: string; + startLat: number; + startLng: number; + endLat: number; + endLng: number; + distanceKm: number; + rawDistanceKm?: number; + startTime: Date; + endTime: Date; + durationMinutes?: number; + segmentType?: SegmentType; + isValid?: boolean; + validationNotes?: string; + encodedPolyline?: string; + metadata?: Record; +} + +export interface RouteSegmentFilters { + incidentId?: string; + unitId?: string; + deviceId?: string; + segmentType?: SegmentType; + isValid?: boolean; + startDate?: Date; + endDate?: Date; +} + +export interface CalculatedRoute { + segments: RouteSegment[]; + totalDistanceKm: number; + totalDurationMinutes: number; + summary: { + toIncidentKm: number; + atIncidentKm: number; + returnKm: number; + }; +} + +export class RouteSegmentService { + private segmentRepository: Repository; + private positionRepository: Repository; + + constructor(dataSource: DataSource) { + this.segmentRepository = dataSource.getRepository(RouteSegment); + this.positionRepository = dataSource.getRepository(GpsPosition); + } + + /** + * Create a route segment + */ + async create(tenantId: string, dto: CreateRouteSegmentDto): Promise { + const durationMinutes = dto.durationMinutes ?? + (dto.endTime.getTime() - dto.startTime.getTime()) / (1000 * 60); + + const segment = this.segmentRepository.create({ + tenantId, + incidentId: dto.incidentId, + unitId: dto.unitId, + deviceId: dto.deviceId, + startPositionId: dto.startPositionId, + endPositionId: dto.endPositionId, + startLat: dto.startLat, + startLng: dto.startLng, + endLat: dto.endLat, + endLng: dto.endLng, + distanceKm: dto.distanceKm, + rawDistanceKm: dto.rawDistanceKm, + startTime: dto.startTime, + endTime: dto.endTime, + durationMinutes, + segmentType: dto.segmentType || SegmentType.OTHER, + isValid: dto.isValid !== false, + validationNotes: dto.validationNotes, + encodedPolyline: dto.encodedPolyline, + metadata: dto.metadata || {}, + }); + + return this.segmentRepository.save(segment); + } + + /** + * Find segment by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.segmentRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * List segments with filters + */ + async findAll( + tenantId: string, + filters: RouteSegmentFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.segmentRepository.createQueryBuilder('segment') + .where('segment.tenant_id = :tenantId', { tenantId }); + + if (filters.incidentId) { + queryBuilder.andWhere('segment.incident_id = :incidentId', { incidentId: filters.incidentId }); + } + if (filters.unitId) { + queryBuilder.andWhere('segment.unit_id = :unitId', { unitId: filters.unitId }); + } + if (filters.deviceId) { + queryBuilder.andWhere('segment.device_id = :deviceId', { deviceId: filters.deviceId }); + } + if (filters.segmentType) { + queryBuilder.andWhere('segment.segment_type = :segmentType', { segmentType: filters.segmentType }); + } + if (filters.isValid !== undefined) { + queryBuilder.andWhere('segment.is_valid = :isValid', { isValid: filters.isValid }); + } + if (filters.startDate) { + queryBuilder.andWhere('segment.start_time >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + queryBuilder.andWhere('segment.end_time <= :endDate', { endDate: filters.endDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('segment.start_time', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get segments for an incident + */ + async findByIncident(tenantId: string, incidentId: string): Promise { + return this.segmentRepository.find({ + where: { tenantId, incidentId }, + order: { startTime: 'ASC' }, + }); + } + + /** + * Calculate route from positions + */ + async calculateRouteFromPositions( + tenantId: string, + deviceId: string, + startTime: Date, + endTime: Date, + incidentId?: string, + segmentType?: SegmentType + ): Promise { + // Get positions in time range + const positions = await this.positionRepository.find({ + where: { + tenantId, + deviceId, + isValid: true, + }, + order: { deviceTime: 'ASC' }, + }); + + // Filter by time range + const filteredPositions = positions.filter( + p => p.deviceTime >= startTime && p.deviceTime <= endTime + ); + + if (filteredPositions.length < 2) { + return null; + } + + const firstPos = filteredPositions[0]; + const lastPos = filteredPositions[filteredPositions.length - 1]; + + // Calculate total distance + let totalDistance = 0; + for (let i = 1; i < filteredPositions.length; i++) { + totalDistance += this.calculateDistance( + Number(filteredPositions[i - 1].latitude), + Number(filteredPositions[i - 1].longitude), + Number(filteredPositions[i].latitude), + Number(filteredPositions[i].longitude) + ); + } + + // Create encoded polyline + const encodedPolyline = this.encodePolyline( + filteredPositions.map(p => ({ + lat: Number(p.latitude), + lng: Number(p.longitude), + })) + ); + + return this.create(tenantId, { + incidentId, + unitId: firstPos.unitId, + deviceId, + startPositionId: firstPos.id, + endPositionId: lastPos.id, + startLat: Number(firstPos.latitude), + startLng: Number(firstPos.longitude), + endLat: Number(lastPos.latitude), + endLng: Number(lastPos.longitude), + distanceKm: Math.round(totalDistance * 1000) / 1000, + rawDistanceKm: totalDistance, + startTime: firstPos.deviceTime, + endTime: lastPos.deviceTime, + segmentType: segmentType || SegmentType.OTHER, + encodedPolyline, + metadata: { + positionCount: filteredPositions.length, + }, + }); + } + + /** + * Get calculated route summary for an incident + */ + async getIncidentRouteSummary(tenantId: string, incidentId: string): Promise { + const segments = await this.findByIncident(tenantId, incidentId); + + if (segments.length === 0) { + return null; + } + + let totalDistanceKm = 0; + let totalDurationMinutes = 0; + let toIncidentKm = 0; + let atIncidentKm = 0; + let returnKm = 0; + + for (const segment of segments) { + if (segment.isValid) { + totalDistanceKm += Number(segment.distanceKm); + totalDurationMinutes += Number(segment.durationMinutes || 0); + + switch (segment.segmentType) { + case SegmentType.TO_INCIDENT: + toIncidentKm += Number(segment.distanceKm); + break; + case SegmentType.AT_INCIDENT: + atIncidentKm += Number(segment.distanceKm); + break; + case SegmentType.RETURN: + returnKm += Number(segment.distanceKm); + break; + } + } + } + + return { + segments, + totalDistanceKm: Math.round(totalDistanceKm * 1000) / 1000, + totalDurationMinutes: Math.round(totalDurationMinutes * 100) / 100, + summary: { + toIncidentKm: Math.round(toIncidentKm * 1000) / 1000, + atIncidentKm: Math.round(atIncidentKm * 1000) / 1000, + returnKm: Math.round(returnKm * 1000) / 1000, + }, + }; + } + + /** + * Update segment validity + */ + async updateValidity( + tenantId: string, + id: string, + isValid: boolean, + notes?: string + ): Promise { + const segment = await this.findById(tenantId, id); + if (!segment) return null; + + segment.isValid = isValid; + if (notes) { + segment.validationNotes = notes; + } + + return this.segmentRepository.save(segment); + } + + /** + * Delete segment + */ + async delete(tenantId: string, id: string): Promise { + const result = await this.segmentRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + /** + * Get distance statistics for a unit + */ + async getUnitStats( + tenantId: string, + unitId: string, + startDate?: Date, + endDate?: Date + ): Promise<{ + totalDistanceKm: number; + totalSegments: number; + avgDistancePerSegment: number; + bySegmentType: Record; + }> { + const queryBuilder = this.segmentRepository.createQueryBuilder('segment') + .where('segment.tenant_id = :tenantId', { tenantId }) + .andWhere('segment.unit_id = :unitId', { unitId }) + .andWhere('segment.is_valid = :isValid', { isValid: true }); + + if (startDate) { + queryBuilder.andWhere('segment.start_time >= :startDate', { startDate }); + } + if (endDate) { + queryBuilder.andWhere('segment.end_time <= :endDate', { endDate }); + } + + const segments = await queryBuilder.getMany(); + + const bySegmentType: Record = { + [SegmentType.TO_INCIDENT]: 0, + [SegmentType.AT_INCIDENT]: 0, + [SegmentType.RETURN]: 0, + [SegmentType.BETWEEN_INCIDENTS]: 0, + [SegmentType.OTHER]: 0, + }; + + let totalDistanceKm = 0; + for (const segment of segments) { + const distance = Number(segment.distanceKm); + totalDistanceKm += distance; + bySegmentType[segment.segmentType] += distance; + } + + return { + totalDistanceKm: Math.round(totalDistanceKm * 1000) / 1000, + totalSegments: segments.length, + avgDistancePerSegment: segments.length > 0 + ? Math.round((totalDistanceKm / segments.length) * 1000) / 1000 + : 0, + bySegmentType, + }; + } + + /** + * Calculate distance using Haversine formula + */ + private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRad(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Encode polyline using Google's algorithm + */ + private encodePolyline(points: { lat: number; lng: number }[]): string { + if (points.length === 0) return ''; + + let encoded = ''; + let prevLat = 0; + let prevLng = 0; + + for (const point of points) { + const lat = Math.round(point.lat * 1e5); + const lng = Math.round(point.lng * 1e5); + + encoded += this.encodeNumber(lat - prevLat); + encoded += this.encodeNumber(lng - prevLng); + + prevLat = lat; + prevLng = lng; + } + + return encoded; + } + + private encodeNumber(num: number): string { + let sgnNum = num << 1; + if (num < 0) { + sgnNum = ~sgnNum; + } + + let encoded = ''; + while (sgnNum >= 0x20) { + encoded += String.fromCharCode((0x20 | (sgnNum & 0x1f)) + 63); + sgnNum >>= 5; + } + encoded += String.fromCharCode(sgnNum + 63); + + return encoded; + } +}