erp-transportistas-v2/docs/30-integraciones/INTEGRACION-GEOFENCING.md
Adrian Flores Cortes 6ed7f9e2ec [BACKUP] Pre-restructure workspace backup 2026-01-29
- Updated docs and inventory files
- Added new architecture docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:35:54 -06:00

27 KiB

Integracion Geofencing (Geocercas Avanzadas)

Proyecto: ERP Transportistas | Version: 1.0.0 | Actualizado: 2026-01-27 Referencia GAP: GAP-006 - Geocercas Avanzadas (dwell time, triggers, categorias)


Descripcion General

El sistema de geofencing de ERP Transportistas permite definir zonas geograficas de interes (geocercas) y detectar automaticamente cuando las unidades de transporte entran o salen de estas zonas. La implementacion utiliza PostGIS para consultas espaciales de alto rendimiento y soporta geocercas circulares y poligonales con multiples categorias y triggers configurables.


Tipos de Geocercas

Geocerca Circular

Definida por un punto central (latitud, longitud) y un radio en metros. Es la mas eficiente computacionalmente y adecuada para ubicaciones puntuales.

       Radio (metros)
           |
           v
    +------+------+
   /               \
  /                 \
 |        X         |  <- Centro (lat, lon)
  \                 /
   \               /
    +------+------+

Casos de uso:

  • Clientes (ubicacion de entrega)
  • Gasolineras
  • Casetas de peaje
  • Puntos de control

Definicion:

{
  "tipo": "circular",
  "centro": {
    "latitud": 19.4326,
    "longitud": -99.1332
  },
  "radioMetros": 500
}

Geocerca Poligonal

Definida por un conjunto de vertices que forman un poligono cerrado en formato GeoJSON. Permite definir zonas irregulares con precision.

         A
        /\
       /  \
      /    \
     /      \
    B--------C
    |        |
    |        |
    D--------E

Casos de uso:

  • Zonas de riesgo (colonias, carreteras)
  • Patios y bodegas (perimetro exacto)
  • Zonas de cobertura de servicio
  • Areas restringidas

Definicion (GeoJSON):

{
  "tipo": "poligonal",
  "poligono": {
    "type": "Polygon",
    "coordinates": [[
      [-99.1400, 19.4400],
      [-99.1200, 19.4400],
      [-99.1200, 19.4200],
      [-99.1400, 19.4200],
      [-99.1400, 19.4400]
    ]]
  }
}

Nota: El primer y ultimo punto deben ser identicos para cerrar el poligono.


Categorias de Geocercas

El sistema soporta las siguientes categorias predefinidas:

Categoria Codigo Descripcion Uso Tipico
Base base Patios, bodegas, centros de distribucion propios Control de salidas/llegadas de flota
Cobertura coverage Zonas geograficas de servicio Validar que servicios esten dentro de zona
Restringida restricted Areas donde no debe transitar la unidad Alertas de desvio, zonas prohibidas
Alto Riesgo high_risk Colonias/carreteras con indice de inseguridad Alertas de precaucion, seguimiento intensivo
Cliente client Ubicaciones de entrega/recoleccion de clientes Deteccion de arribo, calculo de dwell time
Area de Descanso rest_area Estacionamientos, hoteles, areas de descanso Control de HOS, paradas autorizadas
Caseta toll Casetas de peaje Validacion de rutas, control de gastos
Personalizada custom Definida por el usuario Cualquier proposito especifico

Enum en Base de Datos

CREATE TYPE tracking.categoria_geocerca AS ENUM (
  'base',
  'coverage',
  'restricted',
  'high_risk',
  'client',
  'rest_area',
  'toll',
  'custom'
);

Configuracion de Triggers

Cada geocerca puede configurar triggers independientes para entrada, salida y permanencia.

triggerOnEnter

Se activa cuando una unidad ingresa a la geocerca (transicion de fuera a dentro).

{
  "triggerOnEnter": true,
  "onEnter": {
    "generarEvento": true,
    "tipoEvento": "ENTRADA_GEOCERCA",
    "generarAlerta": true,
    "severidadAlerta": "INFO",
    "notificar": {
      "email": ["trafico@empresa.com"],
      "push": true,
      "sms": false
    }
  }
}

triggerOnExit

Se activa cuando una unidad sale de la geocerca (transicion de dentro a fuera).

{
  "triggerOnExit": true,
  "onExit": {
    "generarEvento": true,
    "tipoEvento": "SALIDA_GEOCERCA",
    "generarAlerta": true,
    "severidadAlerta": "INFO",
    "notificar": {
      "email": ["trafico@empresa.com"],
      "push": true
    }
  }
}

dwellTimeSeconds (Tiempo de Permanencia)

Genera un evento/alerta cuando la unidad permanece dentro de la geocerca por mas del tiempo especificado.

{
  "dwellTimeSeconds": 1800,
  "onDwellExceeded": {
    "generarAlerta": true,
    "severidadAlerta": "WARNING",
    "tipoAlerta": "PARADA_PROLONGADA",
    "mensaje": "Unidad {unidad} ha permanecido mas de 30 minutos en {geocerca}",
    "notificar": {
      "email": ["supervisores@empresa.com"],
      "push": true
    }
  }
}

Combinacion de Triggers

Una geocerca puede tener multiples triggers activos:

{
  "codigo": "CLIENTE-WALMART-CDMX-01",
  "nombre": "Walmart CEDIS CDMX",
  "categoria": "client",
  "triggerOnEnter": true,
  "triggerOnExit": true,
  "dwellTimeSeconds": 7200,
  "onEnter": {
    "generarEvento": true,
    "tipoEvento": "ARRIBO_CLIENTE",
    "notificar": { "push": true }
  },
  "onExit": {
    "generarEvento": true,
    "tipoEvento": "SALIDA_CLIENTE"
  },
  "onDwellExceeded": {
    "generarAlerta": true,
    "severidadAlerta": "WARNING",
    "tipoAlerta": "DETENTION_TIME",
    "mensaje": "Detention time excedido: mas de 2 horas en cliente"
  }
}

Entidades de Base de Datos

Geofence (Geocerca)

Tabla: tracking.geocercas

Campo Tipo Descripcion
id UUID Identificador unico
tenant_id UUID Tenant propietario
codigo VARCHAR(50) Codigo unico por tenant
nombre VARCHAR(200) Nombre descriptivo
descripcion TEXT Descripcion detallada
categoria categoria_geocerca Categoria de la geocerca
es_circular BOOLEAN True = circular, False = poligonal
centro_latitud DECIMAL(10,7) Latitud del centro (circular)
centro_longitud DECIMAL(10,7) Longitud del centro (circular)
radio_metros INTEGER Radio en metros (circular)
poligono GEOMETRY(POLYGON, 4326) Geometria PostGIS (poligonal)
trigger_on_enter BOOLEAN Activar trigger de entrada
trigger_on_exit BOOLEAN Activar trigger de salida
dwell_time_seconds INTEGER Tiempo maximo de permanencia
config_triggers JSONB Configuracion detallada de triggers
color VARCHAR(7) Color hex para visualizacion (#RRGGBB)
cliente_id UUID FK a cliente asociado (opcional)
direccion TEXT Direccion textual
activa BOOLEAN Estado activo/inactivo
metadata JSONB Datos adicionales
created_at TIMESTAMPTZ Fecha de creacion
updated_at TIMESTAMPTZ Fecha de actualizacion
created_by_id UUID Usuario creador

DDL:

CREATE TABLE tracking.geocercas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES core.tenants(id),
  codigo VARCHAR(50) NOT NULL,
  nombre VARCHAR(200) NOT NULL,
  descripcion TEXT,
  categoria tracking.categoria_geocerca NOT NULL DEFAULT 'custom',
  es_circular BOOLEAN NOT NULL DEFAULT TRUE,
  centro_latitud DECIMAL(10,7),
  centro_longitud DECIMAL(10,7),
  radio_metros INTEGER,
  poligono GEOMETRY(POLYGON, 4326),
  trigger_on_enter BOOLEAN NOT NULL DEFAULT TRUE,
  trigger_on_exit BOOLEAN NOT NULL DEFAULT TRUE,
  dwell_time_seconds INTEGER,
  config_triggers JSONB DEFAULT '{}',
  color VARCHAR(7) DEFAULT '#3388FF',
  cliente_id UUID REFERENCES partners.clientes(id),
  direccion TEXT,
  activa BOOLEAN NOT NULL DEFAULT TRUE,
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_by_id UUID,

  CONSTRAINT uq_geocerca_tenant_codigo UNIQUE (tenant_id, codigo),
  CONSTRAINT chk_geocerca_circular CHECK (
    (es_circular = TRUE AND centro_latitud IS NOT NULL AND centro_longitud IS NOT NULL AND radio_metros IS NOT NULL)
    OR
    (es_circular = FALSE AND poligono IS NOT NULL)
  )
);

-- Indices
CREATE INDEX idx_geocerca_tenant ON tracking.geocercas(tenant_id);
CREATE INDEX idx_geocerca_categoria ON tracking.geocercas(tenant_id, categoria) WHERE activa = TRUE;
CREATE INDEX idx_geocerca_cliente ON tracking.geocercas(cliente_id) WHERE cliente_id IS NOT NULL;
CREATE INDEX idx_geocerca_geo USING GIST (poligono);

-- RLS
ALTER TABLE tracking.geocercas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_geocercas ON tracking.geocercas
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

GeofenceEvent (Evento de Geocerca)

Tabla: tracking.geofence_events

Campo Tipo Descripcion
id UUID Identificador unico
tenant_id UUID Tenant propietario
geocerca_id UUID FK a geocercas
unidad_id UUID FK a unidades
viaje_id UUID FK a viajes (opcional)
tipo_evento VARCHAR(20) ENTER, EXIT, DWELL_EXCEEDED
posicion_id UUID FK a posiciones_gps
latitud DECIMAL(10,7) Latitud del evento
longitud DECIMAL(10,7) Longitud del evento
timestamp_evento TIMESTAMPTZ Fecha/hora del evento
dwell_time_actual INTEGER Segundos de permanencia (para DWELL)
alerta_generada_id UUID FK a alerta generada (si aplica)
datos JSONB Datos adicionales
created_at TIMESTAMPTZ Fecha de creacion

DDL:

CREATE TABLE tracking.geofence_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES core.tenants(id),
  geocerca_id UUID NOT NULL REFERENCES tracking.geocercas(id),
  unidad_id UUID NOT NULL REFERENCES fleet.unidades(id),
  viaje_id UUID REFERENCES transport.viajes(id),
  tipo_evento VARCHAR(20) NOT NULL,
  posicion_id UUID REFERENCES tracking.posiciones_gps(id),
  latitud DECIMAL(10,7) NOT NULL,
  longitud DECIMAL(10,7) NOT NULL,
  timestamp_evento TIMESTAMPTZ NOT NULL,
  dwell_time_actual INTEGER,
  alerta_generada_id UUID REFERENCES tracking.alertas(id),
  datos JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  CONSTRAINT chk_tipo_evento CHECK (tipo_evento IN ('ENTER', 'EXIT', 'DWELL_EXCEEDED'))
);

-- Indices
CREATE INDEX idx_geofence_event_tenant ON tracking.geofence_events(tenant_id);
CREATE INDEX idx_geofence_event_geocerca ON tracking.geofence_events(geocerca_id);
CREATE INDEX idx_geofence_event_unidad ON tracking.geofence_events(unidad_id);
CREATE INDEX idx_geofence_event_viaje ON tracking.geofence_events(viaje_id) WHERE viaje_id IS NOT NULL;
CREATE INDEX idx_geofence_event_fecha ON tracking.geofence_events(timestamp_evento);

-- RLS
ALTER TABLE tracking.geofence_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_geofence_events ON tracking.geofence_events
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

API Endpoints

Crear Geocerca

POST /api/gps/geofences

Request - Geocerca Circular:

{
  "codigo": "CLIENTE-WALMART-01",
  "nombre": "Walmart CEDIS Cuautitlan",
  "categoria": "client",
  "esCircular": true,
  "centroLatitud": 19.6789,
  "centroLongitud": -99.1234,
  "radioMetros": 300,
  "triggerOnEnter": true,
  "triggerOnExit": true,
  "dwellTimeSeconds": 3600,
  "configTriggers": {
    "onEnter": {
      "generarEvento": true,
      "tipoEvento": "ARRIBO_CLIENTE"
    },
    "onExit": {
      "generarEvento": true,
      "tipoEvento": "SALIDA_CLIENTE"
    },
    "onDwellExceeded": {
      "generarAlerta": true,
      "severidadAlerta": "WARNING"
    }
  },
  "color": "#FF5733",
  "clienteId": "uuid-cliente",
  "direccion": "Av. Industrial 123, Cuautitlan Izcalli"
}

Request - Geocerca Poligonal:

{
  "codigo": "ZONA-RIESGO-ECATEPEC",
  "nombre": "Zona de Alto Riesgo - Ecatepec Centro",
  "categoria": "high_risk",
  "esCircular": false,
  "poligono": {
    "type": "Polygon",
    "coordinates": [[
      [-99.0500, 19.6100],
      [-99.0300, 19.6100],
      [-99.0300, 19.5900],
      [-99.0500, 19.5900],
      [-99.0500, 19.6100]
    ]]
  },
  "triggerOnEnter": true,
  "triggerOnExit": false,
  "configTriggers": {
    "onEnter": {
      "generarAlerta": true,
      "severidadAlerta": "WARNING",
      "tipoAlerta": "ZONA_RIESGO",
      "notificar": {
        "push": true,
        "sms": true
      }
    }
  },
  "color": "#DC3545"
}

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "codigo": "CLIENTE-WALMART-01",
  "nombre": "Walmart CEDIS Cuautitlan",
  "categoria": "client",
  "activa": true,
  "createdAt": "2026-01-27T10:00:00Z"
}

Listar Geocercas

GET /api/gps/geofences

Query Parameters:

Parametro Tipo Descripcion
categoria string Filtrar por categoria
activa boolean Filtrar por estado
clienteId UUID Filtrar por cliente
bbox string Bounding box: minLon,minLat,maxLon,maxLat
page number Pagina (default 1)
limit number Limite (default 50)

Response:

{
  "data": [
    {
      "id": "uuid-1",
      "codigo": "CLIENTE-WALMART-01",
      "nombre": "Walmart CEDIS Cuautitlan",
      "categoria": "client",
      "esCircular": true,
      "centroLatitud": 19.6789,
      "centroLongitud": -99.1234,
      "radioMetros": 300,
      "triggerOnEnter": true,
      "triggerOnExit": true,
      "dwellTimeSeconds": 3600,
      "color": "#FF5733",
      "activa": true
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 125,
    "pages": 3
  }
}

Obtener Geocerca por ID

GET /api/gps/geofences/:id

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "codigo": "CLIENTE-WALMART-01",
  "nombre": "Walmart CEDIS Cuautitlan",
  "descripcion": "Centro de distribucion principal",
  "categoria": "client",
  "esCircular": true,
  "centroLatitud": 19.6789,
  "centroLongitud": -99.1234,
  "radioMetros": 300,
  "triggerOnEnter": true,
  "triggerOnExit": true,
  "dwellTimeSeconds": 3600,
  "configTriggers": {
    "onEnter": { "generarEvento": true, "tipoEvento": "ARRIBO_CLIENTE" },
    "onExit": { "generarEvento": true, "tipoEvento": "SALIDA_CLIENTE" },
    "onDwellExceeded": { "generarAlerta": true, "severidadAlerta": "WARNING" }
  },
  "color": "#FF5733",
  "clienteId": "uuid-cliente",
  "cliente": {
    "id": "uuid-cliente",
    "nombre": "Walmart de Mexico"
  },
  "direccion": "Av. Industrial 123, Cuautitlan Izcalli",
  "activa": true,
  "metadata": {},
  "createdAt": "2026-01-27T10:00:00Z",
  "updatedAt": "2026-01-27T10:00:00Z"
}

Actualizar Geocerca

PUT /api/gps/geofences/:id

Request:

{
  "nombre": "Walmart CEDIS Cuautitlan - Actualizado",
  "radioMetros": 350,
  "dwellTimeSeconds": 5400
}

Eliminar/Desactivar Geocerca

DELETE /api/gps/geofences/:id

Realiza soft-delete (activa = false).


Verificar Posicion en Geocercas

POST /api/gps/geofences/check

Verifica si una posicion esta dentro de alguna geocerca activa.

Request:

{
  "latitud": 19.6790,
  "longitud": -99.1235,
  "categorias": ["client", "high_risk"]
}

Response:

{
  "dentro": true,
  "geocercas": [
    {
      "id": "uuid-1",
      "codigo": "CLIENTE-WALMART-01",
      "nombre": "Walmart CEDIS Cuautitlan",
      "categoria": "client",
      "distanciaMetros": 45
    }
  ]
}

Registrar Evento de Geocerca

POST /api/gps/geofences/events

Endpoint interno para registrar eventos de geocerca (usado por el procesador de posiciones).

Request:

{
  "geocercaId": "uuid-geocerca",
  "unidadId": "uuid-unidad",
  "viajeId": "uuid-viaje",
  "tipoEvento": "ENTER",
  "posicionId": "uuid-posicion",
  "latitud": 19.6790,
  "longitud": -99.1235,
  "timestampEvento": "2026-01-27T14:30:00Z"
}

Consultar Eventos de Geocerca

GET /api/gps/geofences/events

Query Parameters:

Parametro Tipo Descripcion
geocercaId UUID Filtrar por geocerca
unidadId UUID Filtrar por unidad
viajeId UUID Filtrar por viaje
tipoEvento string ENTER, EXIT, DWELL_EXCEEDED
desde datetime Fecha inicio
hasta datetime Fecha fin

Response:

{
  "data": [
    {
      "id": "uuid-evento",
      "geocerca": {
        "id": "uuid-geocerca",
        "codigo": "CLIENTE-WALMART-01",
        "nombre": "Walmart CEDIS Cuautitlan"
      },
      "unidad": {
        "id": "uuid-unidad",
        "numeroEconomico": "T-001"
      },
      "tipoEvento": "ENTER",
      "latitud": 19.6790,
      "longitud": -99.1235,
      "timestampEvento": "2026-01-27T14:30:00Z",
      "dwellTimeActual": null
    },
    {
      "id": "uuid-evento-2",
      "geocerca": {
        "id": "uuid-geocerca",
        "codigo": "CLIENTE-WALMART-01",
        "nombre": "Walmart CEDIS Cuautitlan"
      },
      "unidad": {
        "id": "uuid-unidad",
        "numeroEconomico": "T-001"
      },
      "tipoEvento": "DWELL_EXCEEDED",
      "latitud": 19.6790,
      "longitud": -99.1235,
      "timestampEvento": "2026-01-27T15:30:00Z",
      "dwellTimeActual": 3605
    }
  ]
}

Consultas PostGIS

Verificar si un punto esta dentro de una geocerca circular

-- Usando ST_DWithin para geocercas circulares
SELECT g.id, g.codigo, g.nombre
FROM tracking.geocercas g
WHERE g.tenant_id = current_setting('app.tenant_id')::uuid
  AND g.activa = TRUE
  AND g.es_circular = TRUE
  AND ST_DWithin(
    ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography,
    ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography,
    g.radio_metros
  );

Verificar si un punto esta dentro de una geocerca poligonal

-- Usando ST_Contains para geocercas poligonales
SELECT g.id, g.codigo, g.nombre
FROM tracking.geocercas g
WHERE g.tenant_id = current_setting('app.tenant_id')::uuid
  AND g.activa = TRUE
  AND g.es_circular = FALSE
  AND ST_Contains(
    g.poligono,
    ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)
  );

Buscar todas las geocercas que contienen un punto

-- Consulta combinada para ambos tipos
WITH punto AS (
  SELECT ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326) AS geom
)
SELECT
  g.id,
  g.codigo,
  g.nombre,
  g.categoria,
  CASE
    WHEN g.es_circular THEN
      ST_Distance(
        ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography,
        (SELECT geom FROM punto)::geography
      )
    ELSE
      ST_Distance(
        ST_Centroid(g.poligono)::geography,
        (SELECT geom FROM punto)::geography
      )
  END AS distancia_metros
FROM tracking.geocercas g, punto p
WHERE g.tenant_id = current_setting('app.tenant_id')::uuid
  AND g.activa = TRUE
  AND (
    (g.es_circular = TRUE AND ST_DWithin(
      ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography,
      p.geom::geography,
      g.radio_metros
    ))
    OR
    (g.es_circular = FALSE AND ST_Contains(g.poligono, p.geom))
  )
ORDER BY distancia_metros;

Buscar geocercas dentro de un bounding box

-- Geocercas dentro de un area rectangular (para carga de mapa)
SELECT g.*
FROM tracking.geocercas g
WHERE g.tenant_id = current_setting('app.tenant_id')::uuid
  AND g.activa = TRUE
  AND (
    (g.es_circular = TRUE AND ST_Intersects(
      ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326),
      ST_MakeEnvelope(-99.20, 19.35, -99.05, 19.55, 4326)
    ))
    OR
    (g.es_circular = FALSE AND ST_Intersects(
      g.poligono,
      ST_MakeEnvelope(-99.20, 19.35, -99.05, 19.55, 4326)
    ))
  );

Calcular distancia de un punto al borde de la geocerca

-- Distancia al perimetro (para alertas de aproximacion)
SELECT
  g.id,
  g.codigo,
  CASE
    WHEN g.es_circular THEN
      GREATEST(0, ST_Distance(
        ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography,
        ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography
      ) - g.radio_metros)
    ELSE
      ST_Distance(
        ST_Boundary(g.poligono)::geography,
        ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography
      )
  END AS distancia_al_borde_metros
FROM tracking.geocercas g
WHERE g.tenant_id = current_setting('app.tenant_id')::uuid
  AND g.activa = TRUE;

Procesamiento de Eventos

Motor de Geocercas

El motor de geocercas procesa cada posicion GPS recibida:

// src/modules/tracking/services/geofence-engine.service.ts

@Injectable()
export class GeofenceEngineService {
  constructor(
    private readonly geocercaRepository: GeocercaRepository,
    private readonly geofenceEventRepository: GeofenceEventRepository,
    private readonly alertService: AlertService,
    private readonly dwellTracker: DwellTimeTrackerService,
  ) {}

  async processPosition(position: GpsPosition, unidadId: string, viajeId?: string): Promise<void> {
    // 1. Obtener geocercas activas del tenant
    const geocercas = await this.geocercaRepository.findActiveGeocercas();

    // 2. Verificar cada geocerca
    for (const geocerca of geocercas) {
      const dentroAhora = await this.isInsideGeocerca(position, geocerca);
      const dentroAntes = await this.wasInsideGeocerca(unidadId, geocerca.id);

      // 3. Detectar transiciones
      if (dentroAhora && !dentroAntes) {
        // ENTRADA
        await this.handleEnter(geocerca, position, unidadId, viajeId);
      } else if (!dentroAhora && dentroAntes) {
        // SALIDA
        await this.handleExit(geocerca, position, unidadId, viajeId);
      } else if (dentroAhora && dentroAntes) {
        // DENTRO - verificar dwell time
        await this.checkDwellTime(geocerca, position, unidadId, viajeId);
      }

      // 4. Actualizar estado
      await this.updateGeocercaState(unidadId, geocerca.id, dentroAhora);
    }
  }

  private async handleEnter(
    geocerca: Geocerca,
    position: GpsPosition,
    unidadId: string,
    viajeId?: string
  ): Promise<void> {
    // Registrar evento
    const evento = await this.geofenceEventRepository.create({
      geocercaId: geocerca.id,
      unidadId,
      viajeId,
      tipoEvento: 'ENTER',
      latitud: position.latitude,
      longitud: position.longitude,
      timestampEvento: position.timestamp,
    });

    // Iniciar tracking de dwell time
    if (geocerca.dwellTimeSeconds) {
      await this.dwellTracker.startTracking(unidadId, geocerca.id, position.timestamp);
    }

    // Generar alerta si configurado
    if (geocerca.triggerOnEnter && geocerca.configTriggers?.onEnter?.generarAlerta) {
      await this.alertService.create({
        tipo: geocerca.configTriggers.onEnter.tipoAlerta || 'ENTRADA_GEOCERCA',
        severidad: geocerca.configTriggers.onEnter.severidadAlerta || 'INFO',
        unidadId,
        viajeId,
        geocercaId: geocerca.id,
        titulo: `Entrada a geocerca: ${geocerca.nombre}`,
        mensaje: `La unidad ingreso a ${geocerca.nombre}`,
        latitud: position.latitude,
        longitud: position.longitude,
      });
    }
  }

  private async checkDwellTime(
    geocerca: Geocerca,
    position: GpsPosition,
    unidadId: string,
    viajeId?: string
  ): Promise<void> {
    if (!geocerca.dwellTimeSeconds) return;

    const dwellInfo = await this.dwellTracker.getDwellTime(unidadId, geocerca.id);
    if (!dwellInfo || dwellInfo.alertaGenerada) return;

    const dwellActual = Math.floor((position.timestamp.getTime() - dwellInfo.entradaAt.getTime()) / 1000);

    if (dwellActual >= geocerca.dwellTimeSeconds) {
      // Registrar evento de dwell excedido
      await this.geofenceEventRepository.create({
        geocercaId: geocerca.id,
        unidadId,
        viajeId,
        tipoEvento: 'DWELL_EXCEEDED',
        latitud: position.latitude,
        longitud: position.longitude,
        timestampEvento: position.timestamp,
        dwellTimeActual: dwellActual,
      });

      // Generar alerta
      if (geocerca.configTriggers?.onDwellExceeded?.generarAlerta) {
        await this.alertService.create({
          tipo: geocerca.configTriggers.onDwellExceeded.tipoAlerta || 'PARADA_PROLONGADA',
          severidad: geocerca.configTriggers.onDwellExceeded.severidadAlerta || 'WARNING',
          unidadId,
          viajeId,
          geocercaId: geocerca.id,
          titulo: `Tiempo excedido en: ${geocerca.nombre}`,
          mensaje: `La unidad ha permanecido ${Math.floor(dwellActual / 60)} minutos en ${geocerca.nombre}`,
          latitud: position.latitude,
          longitud: position.longitude,
        });
      }

      // Marcar que ya se genero alerta
      await this.dwellTracker.markAlertGenerated(unidadId, geocerca.id);
    }
  }
}

Servicio de Tracking de Dwell Time

// src/modules/tracking/services/dwell-time-tracker.service.ts

@Injectable()
export class DwellTimeTrackerService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}

  private getKey(unidadId: string, geocercaId: string): string {
    return `dwell:${unidadId}:${geocercaId}`;
  }

  async startTracking(unidadId: string, geocercaId: string, entradaAt: Date): Promise<void> {
    const key = this.getKey(unidadId, geocercaId);
    await this.redis.hset(key, {
      entradaAt: entradaAt.toISOString(),
      alertaGenerada: 'false',
    });
    // TTL de 24 horas
    await this.redis.expire(key, 86400);
  }

  async getDwellTime(unidadId: string, geocercaId: string): Promise<{
    entradaAt: Date;
    alertaGenerada: boolean;
  } | null> {
    const key = this.getKey(unidadId, geocercaId);
    const data = await this.redis.hgetall(key);

    if (!data.entradaAt) return null;

    return {
      entradaAt: new Date(data.entradaAt),
      alertaGenerada: data.alertaGenerada === 'true',
    };
  }

  async markAlertGenerated(unidadId: string, geocercaId: string): Promise<void> {
    const key = this.getKey(unidadId, geocercaId);
    await this.redis.hset(key, 'alertaGenerada', 'true');
  }

  async stopTracking(unidadId: string, geocercaId: string): Promise<void> {
    const key = this.getKey(unidadId, geocercaId);
    await this.redis.del(key);
  }
}

Consideraciones de Rendimiento

Indices Espaciales

PostGIS utiliza indices GIST para consultas espaciales eficientes:

-- Indice para geocercas poligonales
CREATE INDEX idx_geocerca_geo USING GIST (poligono);

-- Indice para busquedas por centro (circulares)
CREATE INDEX idx_geocerca_centro ON tracking.geocercas
  USING GIST (ST_SetSRID(ST_MakePoint(centro_longitud, centro_latitud), 4326))
  WHERE es_circular = TRUE AND activa = TRUE;

Cache de Geocercas

Las geocercas activas se cachean en Redis para reducir consultas a BD:

// Estructura de cache
const cacheKey = `geocercas:${tenantId}`;
const ttl = 300; // 5 minutos

// Al crear/actualizar/eliminar geocerca
await this.redis.del(cacheKey);

Procesamiento por Lotes

Para alto volumen de posiciones, procesar en lotes:

// Procesar posiciones en lotes de 100
const BATCH_SIZE = 100;

async processBatch(positions: GpsPosition[]): Promise<void> {
  // 1. Agrupar por tenant
  const byTenant = groupBy(positions, 'tenantId');

  // 2. Procesar cada tenant en paralelo
  await Promise.all(
    Object.entries(byTenant).map(([tenantId, tenantPositions]) =>
      this.processTenantPositions(tenantId, tenantPositions)
    )
  );
}

Referencias


Integracion Geofencing - ERP Transportistas v1.0.0