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:
parent
e3baa0a480
commit
b927bafeb0
32
src/main.ts
32
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',
|
||||
});
|
||||
|
||||
258
src/modules/gps/controllers/geofence.controller.ts
Normal file
258
src/modules/gps/controllers/geofence.controller.ts
Normal 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;
|
||||
}
|
||||
219
src/modules/gps/controllers/gps-device.controller.ts
Normal file
219
src/modules/gps/controllers/gps-device.controller.ts
Normal 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;
|
||||
}
|
||||
236
src/modules/gps/controllers/gps-position.controller.ts
Normal file
236
src/modules/gps/controllers/gps-position.controller.ts
Normal 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;
|
||||
}
|
||||
9
src/modules/gps/controllers/index.ts
Normal file
9
src/modules/gps/controllers/index.ts
Normal 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';
|
||||
206
src/modules/gps/controllers/route-segment.controller.ts
Normal file
206
src/modules/gps/controllers/route-segment.controller.ts
Normal 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;
|
||||
}
|
||||
94
src/modules/gps/entities/geofence-event.entity.ts
Normal file
94
src/modules/gps/entities/geofence-event.entity.ts
Normal 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;
|
||||
}
|
||||
119
src/modules/gps/entities/geofence.entity.ts
Normal file
119
src/modules/gps/entities/geofence.entity.ts
Normal 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[];
|
||||
}
|
||||
127
src/modules/gps/entities/gps-device.entity.ts
Normal file
127
src/modules/gps/entities/gps-device.entity.ts
Normal 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[];
|
||||
}
|
||||
88
src/modules/gps/entities/gps-position.entity.ts
Normal file
88
src/modules/gps/entities/gps-position.entity.ts
Normal 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;
|
||||
}
|
||||
10
src/modules/gps/entities/index.ts
Normal file
10
src/modules/gps/entities/index.ts
Normal 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';
|
||||
132
src/modules/gps/entities/route-segment.entity.ts
Normal file
132
src/modules/gps/entities/route-segment.entity.ts
Normal 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
42
src/modules/gps/index.ts
Normal 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';
|
||||
459
src/modules/gps/services/geofence.service.ts
Normal file
459
src/modules/gps/services/geofence.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
326
src/modules/gps/services/gps-device.service.ts
Normal file
326
src/modules/gps/services/gps-device.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
390
src/modules/gps/services/gps-position.service.ts
Normal file
390
src/modules/gps/services/gps-position.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/modules/gps/services/index.ts
Normal file
9
src/modules/gps/services/index.ts
Normal 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';
|
||||
428
src/modules/gps/services/route-segment.service.ts
Normal file
428
src/modules/gps/services/route-segment.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user