- 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>
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
/**
|
|
* 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 },
|
|
});
|
|
}
|
|
}
|