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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 01:33:59 -06:00
parent e3baa0a480
commit b927bafeb0
18 changed files with 3184 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

42
src/modules/gps/index.ts Normal file
View File

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

View File

@ -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<string, any>;
category?: GeofenceCategory;
triggerOnEnter?: boolean;
triggerOnExit?: boolean;
dwellTimeSeconds?: number;
color?: string;
metadata?: Record<string, any>;
createdBy?: string;
}
export interface UpdateGeofenceDto {
name?: string;
code?: string;
description?: string;
centerLat?: number;
centerLng?: number;
radiusMeters?: number;
polygonGeojson?: Record<string, any>;
category?: GeofenceCategory;
triggerOnEnter?: boolean;
triggerOnExit?: boolean;
dwellTimeSeconds?: number;
isActive?: boolean;
color?: string;
metadata?: Record<string, any>;
}
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<string, any>;
}
export interface GeofenceCheckResult {
geofenceId: string;
geofenceName: string;
isInside: boolean;
distanceToCenter?: number; // For circle geofences
}
export class GeofenceService {
private geofenceRepository: Repository<Geofence>;
private eventRepository: Repository<GeofenceEvent>;
constructor(dataSource: DataSource) {
this.geofenceRepository = dataSource.getRepository(Geofence);
this.eventRepository = dataSource.getRepository(GeofenceEvent);
}
/**
* Create a new geofence
*/
async create(tenantId: string, dto: CreateGeofenceDto): Promise<Geofence> {
// 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<Geofence | null> {
return this.geofenceRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find geofence by code
*/
async findByCode(tenantId: string, code: string): Promise<Geofence | null> {
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<Geofence | null> {
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<boolean> {
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<string, any>): 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<GeofenceCheckResult[]> {
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<Geofence[]> {
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<GeofenceEvent> {
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<GeofenceCategory, number>;
byType: Record<GeofenceType, number>;
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, number> = {
[GeofenceCategory.BASE]: 0,
[GeofenceCategory.COVERAGE]: 0,
[GeofenceCategory.RESTRICTED]: 0,
[GeofenceCategory.HIGH_RISK]: 0,
[GeofenceCategory.CLIENT]: 0,
[GeofenceCategory.CUSTOM]: 0,
};
const byType: Record<GeofenceType, number> = {
[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,
};
}
}

View File

@ -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<string, any>;
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<string, any>;
}
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<GpsDevice>;
constructor(dataSource: DataSource) {
this.deviceRepository = dataSource.getRepository(GpsDevice);
}
/**
* Register a new GPS device
*/
async create(tenantId: string, dto: CreateGpsDeviceDto): Promise<GpsDevice> {
// 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<GpsDevice | null> {
return this.deviceRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find device by external ID
*/
async findByExternalId(tenantId: string, externalDeviceId: string): Promise<GpsDevice | null> {
return this.deviceRepository.findOne({
where: { tenantId, externalDeviceId },
});
}
/**
* Find device by unit ID
*/
async findByUnitId(tenantId: string, unitId: string): Promise<GpsDevice | null> {
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<GpsDevice | null> {
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<GpsDevice | null> {
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<boolean> {
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<GpsDevice[]> {
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<GpsDevice[]> {
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<GpsPlatform, number>;
byUnitType: Record<UnitType, number>;
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, number> = {
[GpsPlatform.TRACCAR]: 0,
[GpsPlatform.WIALON]: 0,
[GpsPlatform.SAMSARA]: 0,
[GpsPlatform.GEOTAB]: 0,
[GpsPlatform.MANUAL]: 0,
};
const byUnitType: Record<UnitType, number> = {
[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,
};
}
}

View File

@ -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<string, any>;
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<GpsPosition>;
private deviceRepository: Repository<GpsDevice>;
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<GpsPosition> {
// 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<number> {
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<GpsPosition | null> {
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<GpsPosition | null> {
return this.positionRepository.findOne({
where: { tenantId, deviceId },
order: { deviceTime: 'DESC' },
});
}
/**
* Get last positions for multiple devices
*/
async getLastPositions(tenantId: string, deviceIds: string[]): Promise<GpsPosition[]> {
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<PositionPoint[]> {
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<TrackSummary | null> {
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<number> {
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<number> {
return this.positionRepository.count({
where: { tenantId, deviceId },
});
}
}

View File

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

View File

@ -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<string, any>;
}
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<RouteSegment>;
private positionRepository: Repository<GpsPosition>;
constructor(dataSource: DataSource) {
this.segmentRepository = dataSource.getRepository(RouteSegment);
this.positionRepository = dataSource.getRepository(GpsPosition);
}
/**
* Create a route segment
*/
async create(tenantId: string, dto: CreateRouteSegmentDto): Promise<RouteSegment> {
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<RouteSegment | null> {
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<RouteSegment[]> {
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<RouteSegment | null> {
// 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<CalculatedRoute | null> {
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<RouteSegment | null> {
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<boolean> {
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<SegmentType, number>;
}> {
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, number> = {
[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;
}
}