erp-construccion/docs/02-definicion-modulos/MAI-007-rrhh-asistencias/especificaciones/ET-HR-002-asistencia-biometrica.md

26 KiB

ET-HR-002: Asistencia Biométrica y App Móvil

Epic: MAI-007 - RRHH, Asistencias y Nómina RF: RF-HR-002 Tipo: Especificación Técnica Prioridad: Crítica Estado: 🚧 En Implementación Última actualización: 2025-11-17


📱 Arquitectura de App Móvil

┌──────────────────────────────────────────────────┐
│         App Móvil (React Native + Expo)          │
│                                                   │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
│  │ QR Scanner │  │ Biometric  │  │ GPS Check  │ │
│  └────────────┘  └────────────┘  └────────────┘ │
│                                                   │
│  ┌────────────────────────────────────────────┐  │
│  │      Local Database (SQLite + Expo)       │  │
│  │  - Offline queue                          │  │
│  │  - Employee cache                         │  │
│  │  - Attendance records                     │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────┬───────────────────────────┘
                       │ REST API (online)
                       │ Sync service (background)
┌──────────────────────▼───────────────────────────┐
│              Backend (NestJS)                     │
│  ┌────────────────────────────────────────────┐  │
│  │     Attendance Module                      │  │
│  │  - POST /attendance/check-in               │  │
│  │  - POST /attendance/check-out              │  │
│  │  - GET  /attendance/employees/:workId      │  │
│  │  - POST /attendance/sync-offline           │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────▼───────────────────────────┐
│             PostgreSQL Database                   │
│  attendance.attendance_records                    │
│  attendance.attendance_validations                │
└───────────────────────────────────────────────────┘

📁 Estructura de App Móvil

apps/mobile/
├── src/
│   ├── features/
│   │   └── attendance/
│   │       ├── screens/
│   │       │   ├── LoginScreen.tsx
│   │       │   ├── WorkSelectorScreen.tsx
│   │       │   ├── AttendanceDashboardScreen.tsx
│   │       │   ├── QRScannerScreen.tsx
│   │       │   ├── BiometricCaptureScreen.tsx
│   │       │   ├── EmployeeListScreen.tsx
│   │       │   └── HistoryScreen.tsx
│   │       ├── components/
│   │       │   ├── AttendanceCard.tsx
│   │       │   ├── EmployeeCard.tsx
│   │       │   └── SyncStatus.tsx
│   │       ├── services/
│   │       │   ├── attendance.service.ts
│   │       │   ├── biometric.service.ts
│   │       │   ├── gps.service.ts
│   │       │   └── sync.service.ts
│   │       └── db/
│   │           ├── schema.ts
│   │           └── operations.ts
│   ├── navigation/
│   │   └── AppNavigator.tsx
│   ├── api/
│   │   └── client.ts
│   └── utils/
│       ├── validators.ts
│       └── permissions.ts
├── app.json
├── package.json
└── tsconfig.json

🔧 Implementación Backend - Attendance Module

1. AttendanceRecord Entity

Archivo: apps/backend/src/modules/attendance/entities/attendance-record.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
  Index,
} from 'typeorm';
import { Employee } from '../../hr/employees/entities/employee.entity';
import { Project } from '../../projects/entities/project.entity';

export enum AttendanceType {
  CHECK_IN = 'check_in',
  CHECK_OUT = 'check_out',
}

export enum CaptureMethod {
  QR = 'qr',
  BIOMETRIC = 'biometric',
  MANUAL = 'manual',
  GPS = 'gps',
}

@Entity('attendance_records', { schema: 'attendance' })
@Index(['employeeId', 'workDate'])
@Index(['workId', 'workDate'])
export class AttendanceRecord {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid' })
  employeeId: string;

  @ManyToOne(() => Employee)
  @JoinColumn({ name: 'employeeId' })
  employee: Employee;

  @Column({ type: 'uuid' })
  workId: string;

  @ManyToOne(() => Project)
  @JoinColumn({ name: 'workId' })
  work: Project;

  @Column({ type: 'date' })
  workDate: Date;

  @Column({
    type: 'enum',
    enum: AttendanceType,
  })
  type: AttendanceType;

  @Column({ type: 'timestamptz' })
  timestamp: Date;

  // Método de captura
  @Column({
    type: 'enum',
    enum: CaptureMethod,
  })
  captureMethod: CaptureMethod;

  // Datos biométricos
  @Column({ type: 'jsonb', nullable: true })
  biometricData: {
    confidence: number; // 0-100
    template: string; // Hash del template biométrico
  };

  // Datos de ubicación (GPS)
  @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
  latitude: number;

  @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
  longitude: number;

  @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true })
  distanceFromWork: number; // Metros

  // Foto del empleado (selfie)
  @Column({ type: 'text', nullable: true })
  photoUrl: string;

  // QR code escaneado
  @Column({ type: 'text', nullable: true })
  qrCodeScanned: string;

  // Notas opcionales
  @Column({ type: 'text', nullable: true })
  notes: string;

  // Validaciones
  @Column({ type: 'boolean', default: false })
  isValidated: boolean;

  @Column({ type: 'boolean', default: false })
  hasWarnings: boolean;

  @Column({ type: 'jsonb', default: [] })
  warnings: string[];

  // Dispositivo que registró
  @Column({ type: 'varchar', length: 255, nullable: true })
  deviceId: string;

  @Column({ type: 'varchar', length: 50, nullable: true })
  deviceModel: string;

  @Column({ type: 'varchar', length: 50, nullable: true })
  appVersion: string;

  // Usuario que registró (residente)
  @Column({ type: 'uuid' })
  registeredBy: string;

  @CreateDateColumn()
  createdAt: Date;
}

2. AttendanceService

Archivo: apps/backend/src/modules/attendance/attendance.service.ts

import {
  Injectable,
  BadRequestException,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AttendanceRecord, AttendanceType, CaptureMethod } from './entities/attendance-record.entity';
import { Employee } from '../hr/employees/entities/employee.entity';
import { Project } from '../projects/entities/project.entity';

interface CheckInDto {
  employeeId: string;
  workId: string;
  captureMethod: CaptureMethod;
  latitude?: number;
  longitude?: number;
  biometricData?: { confidence: number; template: string };
  photoUrl?: string;
  qrCodeScanned?: string;
  notes?: string;
  deviceId?: string;
  deviceModel?: string;
  appVersion?: string;
}

@Injectable()
export class AttendanceService {
  constructor(
    @InjectRepository(AttendanceRecord)
    private attendanceRepo: Repository<AttendanceRecord>,
    @InjectRepository(Employee)
    private employeeRepo: Repository<Employee>,
    @InjectRepository(Project)
    private projectRepo: Repository<Project>,
  ) {}

  /**
   * Registrar check-in
   */
  async checkIn(dto: CheckInDto, registeredBy: string): Promise<AttendanceRecord> {
    // Validar empleado existe y está activo
    const employee = await this.employeeRepo.findOne({
      where: { id: dto.employeeId },
    });

    if (!employee) {
      throw new NotFoundException('Empleado no encontrado');
    }

    if (employee.status !== 'active') {
      throw new BadRequestException(
        `Empleado está ${employee.status}. No puede registrar asistencia.`,
      );
    }

    // Validar obra existe y está activa
    const work = await this.projectRepo.findOne({
      where: { id: dto.workId },
    });

    if (!work) {
      throw new NotFoundException('Obra no encontrada');
    }

    // Validar que no haya check-in duplicado hoy
    const today = new Date().toISOString().split('T')[0];
    const existingCheckIn = await this.attendanceRepo.findOne({
      where: {
        employeeId: dto.employeeId,
        workDate: new Date(today),
        type: AttendanceType.CHECK_IN,
      },
    });

    if (existingCheckIn) {
      throw new BadRequestException(
        'Ya existe un check-in para este empleado el día de hoy',
      );
    }

    // Validaciones GPS
    const warnings: string[] = [];
    let distanceFromWork: number | null = null;

    if (dto.latitude && dto.longitude && work.latitude && work.longitude) {
      distanceFromWork = this.calculateDistance(
        dto.latitude,
        dto.longitude,
        work.latitude,
        work.longitude,
      );

      // Advertencia si está fuera del radio (100m)
      if (distanceFromWork > 100) {
        warnings.push(
          `Empleado registró a ${distanceFromWork.toFixed(0)}m de la obra (límite: 100m)`,
        );
      }
    }

    // Validación biométrica
    if (dto.biometricData && dto.biometricData.confidence < 70) {
      warnings.push(
        `Confianza biométrica baja: ${dto.biometricData.confidence}% (mínimo: 70%)`,
      );
    }

    // Crear registro
    const record = this.attendanceRepo.create({
      ...dto,
      workDate: new Date(today),
      type: AttendanceType.CHECK_IN,
      timestamp: new Date(),
      distanceFromWork,
      warnings,
      hasWarnings: warnings.length > 0,
      isValidated: warnings.length === 0, // Auto-aprobar si no hay warnings
      registeredBy,
    });

    return await this.attendanceRepo.save(record);
  }

  /**
   * Registrar check-out
   */
  async checkOut(dto: CheckInDto, registeredBy: string): Promise<AttendanceRecord> {
    // Validar que exista check-in previo
    const today = new Date().toISOString().split('T')[0];
    const checkIn = await this.attendanceRepo.findOne({
      where: {
        employeeId: dto.employeeId,
        workDate: new Date(today),
        type: AttendanceType.CHECK_IN,
      },
    });

    if (!checkIn) {
      throw new BadRequestException(
        'No existe un check-in previo para este empleado el día de hoy',
      );
    }

    // Validar que no haya check-out duplicado
    const existingCheckOut = await this.attendanceRepo.findOne({
      where: {
        employeeId: dto.employeeId,
        workDate: new Date(today),
        type: AttendanceType.CHECK_OUT,
      },
    });

    if (existingCheckOut) {
      throw new BadRequestException('Ya existe un check-out para este empleado el día de hoy');
    }

    // Calcular horas trabajadas
    const hoursWorked = this.calculateHours(checkIn.timestamp, new Date());
    const warnings: string[] = [];

    if (hoursWorked < 4) {
      warnings.push(`Horas trabajadas < 4 (${hoursWorked.toFixed(1)}h)`);
    }

    // Crear check-out
    const record = this.attendanceRepo.create({
      ...dto,
      workDate: new Date(today),
      type: AttendanceType.CHECK_OUT,
      timestamp: new Date(),
      warnings,
      hasWarnings: warnings.length > 0,
      registeredBy,
    });

    return await this.attendanceRepo.save(record);
  }

  /**
   * Obtener empleados de una obra para lista
   */
  async getWorkEmployees(workId: string): Promise<Employee[]> {
    // Obtener empleados asignados a la obra que están activos
    const employees = await this.employeeRepo
      .createQueryBuilder('employee')
      .innerJoin('hr.employee_work_assignments', 'assignment', 'assignment.employee_id = employee.id')
      .where('assignment.work_id = :workId', { workId })
      .andWhere('assignment.end_date IS NULL')
      .andWhere('employee.status = :status', { status: 'active' })
      .orderBy('employee.last_name', 'ASC')
      .getMany();

    return employees;
  }

  /**
   * Sincronizar registros offline
   */
  async syncOfflineRecords(
    records: CheckInDto[],
    registeredBy: string,
  ): Promise<{ success: number; failed: number; errors: string[] }> {
    let success = 0;
    let failed = 0;
    const errors: string[] = [];

    for (const record of records) {
      try {
        if (record.type === AttendanceType.CHECK_IN) {
          await this.checkIn(record, registeredBy);
        } else {
          await this.checkOut(record, registeredBy);
        }
        success++;
      } catch (error) {
        failed++;
        errors.push(`${record.employeeId}: ${error.message}`);
      }
    }

    return { success, failed, errors };
  }

  /**
   * Calcular distancia entre dos puntos GPS (fórmula de Haversine)
   */
  private calculateDistance(
    lat1: number,
    lon1: number,
    lat2: number,
    lon2: number,
  ): number {
    const R = 6371e3; // Radio de la Tierra en metros
    const φ1 = (lat1 * Math.PI) / 180;
    const φ2 = (lat2 * Math.PI) / 180;
    const Δφ = ((lat2 - lat1) * Math.PI) / 180;
    const Δλ = ((lon2 - lon1) * Math.PI) / 180;

    const a =
      Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
      Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

    return R * c; // Distancia en metros
  }

  /**
   * Calcular horas trabajadas
   */
  private calculateHours(checkIn: Date, checkOut: Date): number {
    const diff = checkOut.getTime() - checkIn.getTime();
    return diff / (1000 * 60 * 60); // Convertir a horas
  }
}

📱 Implementación App Móvil

1. Dependencias (package.json)

{
  "dependencies": {
    "expo": "~50.0.0",
    "expo-camera": "~14.0.0",
    "expo-location": "~16.0.0",
    "expo-sqlite": "~13.0.0",
    "react-native-biometrics": "^3.0.1",
    "react-native-qrcode-scanner": "^1.5.5",
    "@react-navigation/native": "^6.1.9",
    "@react-navigation/stack": "^6.3.20",
    "axios": "^1.6.2",
    "zustand": "^4.4.7"
  }
}

2. QR Scanner Screen

Archivo: apps/mobile/src/features/attendance/screens/QRScannerScreen.tsx

import React, { useState } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { BarCodeScanner } from 'expo-barcode-scanner';
import { useAttendanceService } from '../services/attendance.service';

export function QRScannerScreen({ navigation, route }) {
  const { workId } = route.params;
  const [hasPermission, setHasPermission] = useState<boolean | null>(null);
  const [scanned, setScanned] = useState(false);
  const attendanceService = useAttendanceService();

  React.useEffect(() => {
    (async () => {
      const { status } = await BarCodeScanner.requestPermissionsAsync();
      setHasPermission(status === 'granted');
    })();
  }, []);

  const handleBarCodeScanned = async ({ type, data }) => {
    setScanned(true);

    try {
      // Decodificar QR (contiene employee ID)
      const employeeId = decodeQRCode(data);

      // Obtener ubicación actual
      const location = await getLocation();

      // Registrar check-in
      await attendanceService.checkIn({
        employeeId,
        workId,
        captureMethod: 'qr',
        qrCodeScanned: data,
        latitude: location.latitude,
        longitude: location.longitude,
      });

      Alert.alert(
        'Check-in Exitoso',
        'Asistencia registrada correctamente',
        [
          {
            text: 'OK',
            onPress: () => navigation.goBack(),
          },
        ],
      );
    } catch (error) {
      Alert.alert('Error', error.message);
      setScanned(false);
    }
  };

  if (hasPermission === null) {
    return <Text>Solicitando permiso de cámara...</Text>;
  }

  if (hasPermission === false) {
    return <Text>No hay acceso a la cámara</Text>;
  }

  return (
    <View style={styles.container}>
      <BarCodeScanner
        onBarCodeScanned={scanned ? undefined : handleBarCodeScanned}
        style={StyleSheet.absoluteFillObject}
      />

      <View style={styles.overlay}>
        <View style={styles.frame} />
        <Text style={styles.instruction}>
          Escanea el QR del empleado
        </Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  overlay: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'center',
    alignItems: 'center',
  },
  frame: {
    width: 250,
    height: 250,
    borderWidth: 2,
    borderColor: '#fff',
    backgroundColor: 'transparent',
  },
  instruction: {
    marginTop: 20,
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

3. Biometric Capture Screen

Archivo: apps/mobile/src/features/attendance/screens/BiometricCaptureScreen.tsx

import React, { useState } from 'react';
import { View, Text, Button, Alert } from 'react-native';
import ReactNativeBiometrics from 'react-native-biometrics';
import { useAttendanceService } from '../services/attendance.service';

const rnBiometrics = new ReactNativeBiometrics();

export function BiometricCaptureScreen({ navigation, route }) {
  const { employee, workId } = route.params;
  const [isScanning, setIsScanning] = useState(false);
  const attendanceService = useAttendanceService();

  const handleBiometricScan = async () => {
    setIsScanning(true);

    try {
      // Verificar si el dispositivo soporta biométrico
      const { available, biometryType } = await rnBiometrics.isSensorAvailable();

      if (!available) {
        throw new Error('Dispositivo no soporta biométrico');
      }

      // Solicitar biométrico
      const { success, signature } = await rnBiometrics.createSignature({
        promptMessage: 'Coloca tu huella para confirmar asistencia',
        payload: employee.id,
      });

      if (!success) {
        throw new Error('Biométrico no reconocido');
      }

      // Verificar contra template almacenado (simulado)
      const confidence = await verifyBiometric(signature, employee.biometricTemplate);

      // Obtener ubicación
      const location = await getLocation();

      // Registrar check-in
      await attendanceService.checkIn({
        employeeId: employee.id,
        workId,
        captureMethod: 'biometric',
        biometricData: {
          confidence,
          template: signature,
        },
        latitude: location.latitude,
        longitude: location.longitude,
      });

      Alert.alert(
        'Check-in Exitoso',
        `Asistencia registrada. Confianza: ${confidence}%`,
        [{ text: 'OK', onPress: () => navigation.goBack() }],
      );
    } catch (error) {
      Alert.alert('Error', error.message);
    } finally {
      setIsScanning(false);
    }
  };

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text style={{ fontSize: 18, marginBottom: 20 }}>
        {employee.fullName}
      </Text>
      <Text style={{ marginBottom: 40 }}>
        {employee.primaryTrade}
      </Text>

      <Button
        title={isScanning ? 'Escaneando...' : 'Capturar Huella'}
        onPress={handleBiometricScan}
        disabled={isScanning}
      />
    </View>
  );
}

// Función simulada de verificación biométrica
async function verifyBiometric(signature: string, template: string): Promise<number> {
  // En producción, esto llamaría a un servicio de matching biométrico
  // Por ahora, retornamos un valor simulado
  return Math.floor(Math.random() * 30) + 70; // 70-100%
}

4. Offline Database (SQLite)

Archivo: apps/mobile/src/features/attendance/db/schema.ts

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabase('attendance.db');

export const initDatabase = () => {
  db.transaction((tx) => {
    // Tabla de cola de sincronización
    tx.executeSql(
      `CREATE TABLE IF NOT EXISTS sync_queue (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        employee_id TEXT NOT NULL,
        work_id TEXT NOT NULL,
        type TEXT NOT NULL,
        timestamp TEXT NOT NULL,
        capture_method TEXT NOT NULL,
        latitude REAL,
        longitude REAL,
        biometric_data TEXT,
        photo_url TEXT,
        qr_code_scanned TEXT,
        notes TEXT,
        synced INTEGER DEFAULT 0,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
      )`,
    );

    // Cache de empleados
    tx.executeSql(
      `CREATE TABLE IF NOT EXISTS employees_cache (
        id TEXT PRIMARY KEY,
        employee_code TEXT,
        full_name TEXT,
        primary_trade TEXT,
        photo_url TEXT,
        qr_code TEXT,
        biometric_template TEXT,
        work_id TEXT,
        updated_at TEXT
      )`,
    );
  });
};

export const addToSyncQueue = (record) => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        `INSERT INTO sync_queue (
          employee_id, work_id, type, timestamp, capture_method,
          latitude, longitude, biometric_data, photo_url,
          qr_code_scanned, notes
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          record.employeeId,
          record.workId,
          record.type,
          record.timestamp,
          record.captureMethod,
          record.latitude,
          record.longitude,
          JSON.stringify(record.biometricData),
          record.photoUrl,
          record.qrCodeScanned,
          record.notes,
        ],
        (_, result) => resolve(result),
        (_, error) => reject(error),
      );
    });
  });
};

export const getPendingSyncRecords = () => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        'SELECT * FROM sync_queue WHERE synced = 0 ORDER BY created_at ASC',
        [],
        (_, { rows }) => resolve(rows._array),
        (_, error) => reject(error),
      );
    });
  });
};

export const markRecordAsSynced = (id) => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        'UPDATE sync_queue SET synced = 1 WHERE id = ?',
        [id],
        (_, result) => resolve(result),
        (_, error) => reject(error),
      );
    });
  });
};

5. Sync Service

Archivo: apps/mobile/src/features/attendance/services/sync.service.ts

import { useEffect, useRef } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { apiClient } from '@/api/client';
import {
  getPendingSyncRecords,
  markRecordAsSynced,
} from '../db/schema';

export function useSyncService() {
  const syncIntervalRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    // Listener de cambio de conectividad
    const unsubscribe = NetInfo.addEventListener((state) => {
      if (state.isConnected) {
        syncPendingRecords();
      }
    });

    // Sync cada 5 minutos
    syncIntervalRef.current = setInterval(() => {
      syncPendingRecords();
    }, 5 * 60 * 1000);

    return () => {
      unsubscribe();
      if (syncIntervalRef.current) {
        clearInterval(syncIntervalRef.current);
      }
    };
  }, []);

  const syncPendingRecords = async () => {
    try {
      const records = await getPendingSyncRecords();

      if (records.length === 0) {
        return;
      }

      console.log(`Sincronizando ${records.length} registros...`);

      const response = await apiClient.post('/attendance/sync-offline', {
        records: records.map((r) => ({
          employeeId: r.employee_id,
          workId: r.work_id,
          type: r.type,
          timestamp: r.timestamp,
          captureMethod: r.capture_method,
          latitude: r.latitude,
          longitude: r.longitude,
          biometricData: r.biometric_data ? JSON.parse(r.biometric_data) : null,
          photoUrl: r.photo_url,
          qrCodeScanned: r.qr_code_scanned,
          notes: r.notes,
        })),
      });

      // Marcar como sincronizados
      for (const record of records) {
        await markRecordAsSynced(record.id);
      }

      console.log(`✅ Sincronizados ${response.data.success} registros`);
    } catch (error) {
      console.error('Error sincronizando:', error);
    }
  };

  return {
    syncNow: syncPendingRecords,
  };
}

🧪 Tests

describe('AttendanceService', () => {
  it('should register check-in with QR', async () => {
    const dto = {
      employeeId: 'employee-id',
      workId: 'work-id',
      captureMethod: CaptureMethod.QR,
      qrCodeScanned: 'QR-DATA-123',
      latitude: 19.4326,
      longitude: -99.1332,
    };

    const record = await service.checkIn(dto, 'user-id');

    expect(record.type).toBe(AttendanceType.CHECK_IN);
    expect(record.captureMethod).toBe(CaptureMethod.QR);
    expect(record.distanceFromWork).toBeLessThan(100);
  });

  it('should reject duplicate check-in', async () => {
    // ... registrar check-in
    await expect(service.checkIn(dto, 'user-id')).rejects.toThrow(
      'Ya existe un check-in',
    );
  });

  it('should calculate GPS distance correctly', () => {
    const distance = service['calculateDistance'](
      19.4326, -99.1332, // CDMX
      19.4340, -99.1350, // ~170m de distancia
    );
    expect(distance).toBeGreaterThan(150);
    expect(distance).toBeLessThan(200);
  });
});

Fecha de creación: 2025-11-17 Versión: 1.0