erp-mecanicas-diesel-backen.../src/modules/gps/services/gps-position.service.ts
Adrian Flores Cortes b927bafeb0 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>
2026-01-27 01:33:59 -06:00

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