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 { createSupplierController } from './modules/parts-management/controllers/supplier.controller';
|
||||||
import { createCustomersRouter } from './modules/customers/controllers/customers.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
|
// Payment Terminals Module
|
||||||
import { PaymentTerminalsModule } from './modules/payment-terminals';
|
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 { TerminalPayment } from './modules/payment-terminals/entities/terminal-payment.entity';
|
||||||
import { TerminalWebhookEvent } from './modules/payment-terminals/entities/terminal-webhook-event.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
|
// Load environment variables
|
||||||
config();
|
config();
|
||||||
|
|
||||||
@ -101,6 +114,12 @@ const AppDataSource = new DataSource({
|
|||||||
TenantTerminalConfig,
|
TenantTerminalConfig,
|
||||||
TerminalPayment,
|
TerminalPayment,
|
||||||
TerminalWebhookEvent,
|
TerminalWebhookEvent,
|
||||||
|
// GPS
|
||||||
|
GpsDevice,
|
||||||
|
GpsPosition,
|
||||||
|
Geofence,
|
||||||
|
GeofenceEvent,
|
||||||
|
RouteSegment,
|
||||||
],
|
],
|
||||||
synchronize: process.env.NODE_ENV === 'development',
|
synchronize: process.env.NODE_ENV === 'development',
|
||||||
logging: 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/suppliers', createSupplierController(AppDataSource));
|
||||||
app.use('/api/v1/customers', createCustomersRouter(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
|
// Payment Terminals Module
|
||||||
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
|
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
|
||||||
app.use('/api/v1', paymentTerminals.router);
|
app.use('/api/v1', paymentTerminals.router);
|
||||||
@ -172,6 +198,12 @@ async function bootstrap() {
|
|||||||
paymentTerminals: '/api/v1/payment-terminals',
|
paymentTerminals: '/api/v1/payment-terminals',
|
||||||
mercadopago: '/api/v1/mercadopago',
|
mercadopago: '/api/v1/mercadopago',
|
||||||
clip: '/api/v1/clip',
|
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',
|
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