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