- Updated docs and inventory files - Added new architecture docs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1071 lines
27 KiB
Markdown
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*
|