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

1071 lines
27 KiB
Markdown

# 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:**
```json
{
"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):**
```json
{
"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
```sql
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).
```json
{
"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).
```json
{
"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.
```json
{
"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:
```json
{
"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:**
```sql
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:**
```sql
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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"latitud": 19.6790,
"longitud": -99.1235,
"categorias": ["client", "high_risk"]
}
```
**Response:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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
```sql
-- 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
```sql
-- 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
```sql
-- 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
```sql
-- 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
```sql
-- 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:
```typescript
// 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
```typescript
// 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:
```sql
-- 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:
```typescript
// 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:
```typescript
// 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
- [PostGIS Documentation](https://postgis.net/documentation/)
- [GeoJSON Specification](https://geojson.org/)
- [WGS 84 (SRID 4326)](https://epsg.io/4326)
- [ST_DWithin Documentation](https://postgis.net/docs/ST_DWithin.html)
- [ST_Contains Documentation](https://postgis.net/docs/ST_Contains.html)
---
*Integracion Geofencing - ERP Transportistas v1.0.0*